@web-my-money/studio-consumer 2.3.0 → 2.4.1

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/README.md CHANGED
@@ -7,10 +7,10 @@ are Next.js apps that already transpile workspace and scoped packages, so
7
7
  publishing compiled output here would only add a compile-and-publish cycle
8
8
  for no gain.
9
9
 
10
- ## Connect a site: one command (2.3.0)
10
+ ## Connect a site: one command (2.3.0; new sites 2.4.0)
11
11
 
12
12
  In Studio, open the site, Site settings, **Get the setup command**, then in the
13
- app's folder:
13
+ app's folder (or, for a brand-new site, in an empty folder):
14
14
 
15
15
  ```bash
16
16
  npx @web-my-money/studio-consumer init <code>
@@ -23,6 +23,11 @@ editable text, verifies, opens a pull request, waits for the deploy and asks
23
23
  Studio to sync. Rerun it after any stop; it resumes. `init --explain` shows the
24
24
  plan without changing anything.
25
25
 
26
+ In an empty folder (2.4.0) it first creates the client's private repository from
27
+ `Web-My-Money/wmm-site-template`, pulls it in and puts the site's key and name in
28
+ place of the template's markers; the Claude step then sets the client's colours
29
+ and first copy instead of wiring a layout.
30
+
26
31
  ## Entry points
27
32
 
28
33
  - `@web-my-money/studio-consumer/analytics`
@@ -104,3 +109,15 @@ export const POST = createCollectHandler({
104
109
 
105
110
  Without it, events carry no arm and the site stays out of any A/B comparison.
106
111
 
112
+
113
+ ## Visitor identity (2.4.1)
114
+
115
+ Two fixes taken in from WMM Website, where they had been made after the package was split out:
116
+
117
+ - The browser collector writes the `wmm_vid` cookie itself when the page arrived without one, so a
118
+ site may set the cookie in its proxy only on routes that run A/B tests (keeping every other page
119
+ CDN-cacheable) and a visitor still keeps one identity across the site. Arms are labelled only from
120
+ the cookie the page arrived with, never from one the browser just wrote.
121
+ - `withVisitorCookie` puts a new visitor's id on the request as well as the response, so their very
122
+ first render is bucketed. Before, a first visit rendered control while its events carried the new
123
+ id's arm.
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  /**
7
- * Step 7 of `init`: the edits that need judgement on someone else's code (root
7
+ * Step 8 of `init`: the edits that need judgement on someone else's code (root
8
8
  * layout, proxy/middleware, which text is editable) are done by Claude Code, on
9
9
  * the developer's own subscription, through the `onboard-site` skill this
10
10
  * package ships. Each edit is shown and approved before it is written.
package/cli/init.mjs CHANGED
@@ -9,6 +9,7 @@ import { preflight } from "./preflight.mjs";
9
9
  import { cliProvisioner } from "./provisioner.mjs";
10
10
  import { writeSiteFiles, wrapNextConfig } from "./writers.mjs";
11
11
  import { claudeStep } from "./claude-step.mjs";
12
+ import { personalise, pullTemplateRepo, templateKeyProblem } from "./scaffold.mjs";
12
13
 
13
14
  /**
14
15
  * `npx @web-my-money/studio-consumer init <code>`: connect an existing Next.js
@@ -37,7 +38,7 @@ const POLL_TIMES = 60;
37
38
  * cwd: string, ui: Ui, run: Runner, fetchImpl: Fetch, sleep: (ms: number) => Promise<void>,
38
39
  * studioUrl: string, code: string | null, state: OnboardingState | null,
39
40
  * provisioner: AnyProvisioner | null, provisionerScope?: string,
40
- * injectedProvisioner: AnyProvisioner | null,
41
+ * injectedProvisioner: AnyProvisioner | null, mode?: "new" | "existing",
41
42
  * }} Ctx
42
43
  */
43
44
 
@@ -48,7 +49,8 @@ const STEPS = [
48
49
  title: "Check your setup",
49
50
  why: "Makes sure Node, GitHub, Vercel and this app are ready before anything changes.",
50
51
  async run(ctx) {
51
- await preflight({ cwd: ctx.cwd, run: ctx.run, ui: ctx.ui, resuming: ctx.state !== null });
52
+ const r = await preflight({ cwd: ctx.cwd, run: ctx.run, ui: ctx.ui, resuming: ctx.state !== null });
53
+ ctx.mode = r.mode;
52
54
  },
53
55
  },
54
56
  {
@@ -89,13 +91,61 @@ const STEPS = [
89
91
  ctx.ui.info(`Connected to ${ctx.state.siteName} (${ctx.state.siteKey}). Modules: ${ctx.state.modules.join(", ")}.`);
90
92
  },
91
93
  },
94
+ {
95
+ id: "create",
96
+ title: "Create the site from WMM's template",
97
+ why: "For a new site: makes the client's own repository from WMM's site template and puts it in this folder.",
98
+ async run(ctx) {
99
+ const s = need(ctx);
100
+ // Saved state wins over this run's preflight: once the template is checked
101
+ // out the folder looks like an existing app, but it still needs finishing.
102
+ if (ctx.mode !== "new" && s.mode !== "new") {
103
+ return ctx.ui.info("This is an existing app, so there is nothing to create.");
104
+ }
105
+ const problem = templateKeyProblem(s.siteKey);
106
+ if (problem) ctx.ui.stop(problem);
107
+ const repo = `Web-My-Money/${s.siteKey}`;
108
+ if (!s.repoCreated) {
109
+ const ok = await ctx.ui.confirm(`Create the private repository ${repo} from WMM's site template, in this folder?`);
110
+ if (!ok) ctx.ui.stop("A new site needs its repository. Run this again when you are ready.");
111
+ try {
112
+ await provisioner(ctx).createRepoFromTemplate(s.siteKey);
113
+ ctx.ui.info(`Created ${repo}.`);
114
+ } catch (e) {
115
+ if (!(e instanceof Error && /already exists/i.test(e.message))) throw e;
116
+ // Most often an earlier run created it and stopped before saving that.
117
+ const mine = await ctx.ui.confirm(
118
+ `${repo} already exists on GitHub. Is it this site's repository, made from WMM's template (for example by an earlier run of this command)? Pull it into this folder?`,
119
+ );
120
+ if (!mine) {
121
+ ctx.ui.stop(
122
+ `${repo} already exists, so this command will not create it again. If it is this site, clone it and run this command inside it.`,
123
+ `gh repo clone ${repo}`,
124
+ );
125
+ }
126
+ }
127
+ // Remembered at once: a rerun after a later stop must neither create
128
+ // the repository twice nor forget that this is a new site.
129
+ s.repoCreated = true;
130
+ s.mode = "new";
131
+ saveState(ctx.cwd, s);
132
+ }
133
+ const pulled = await pullTemplateRepo({ cwd: ctx.cwd, run: ctx.run, sleep: ctx.sleep, name: s.siteKey });
134
+ if (!pulled.ok) ctx.ui.stop(pulled.message, pulled.nextCommand);
135
+ const changed = await personalise({ cwd: ctx.cwd, run: ctx.run, siteKey: s.siteKey, siteName: s.siteName });
136
+ // The template's colours are scoped to the site key, so it is the theme.
137
+ s.mode = "new";
138
+ s.theme = s.siteKey;
139
+ ctx.ui.info(`Made the template ${s.siteName}'s (${changed.length} files).`);
140
+ },
141
+ },
92
142
  {
93
143
  id: "install",
94
144
  title: "Install the package",
95
145
  why: "Adds WMM Studio's package to this app.",
96
146
  async run(ctx) {
97
- const r = await ctx.run("npm", ["install", "--install-links", "@web-my-money/studio-consumer@^2.3"], { cwd: ctx.cwd });
98
- if (r.code !== 0) ctx.ui.stop("npm could not install the package.", "npm install --install-links @web-my-money/studio-consumer@^2.3");
147
+ const r = await ctx.run("npm", ["install", "--install-links", "@web-my-money/studio-consumer@^2.4"], { cwd: ctx.cwd });
148
+ if (r.code !== 0) ctx.ui.stop("npm could not install the package.", "npm install --install-links @web-my-money/studio-consumer@^2.4");
99
149
  },
100
150
  },
101
151
  {
@@ -104,7 +154,7 @@ const STEPS = [
104
154
  why: "Writes the small files Studio talks to, and lets Studio show this site inside its editor.",
105
155
  async run(ctx) {
106
156
  const s = need(ctx);
107
- if (s.modules.includes("content")) {
157
+ if (s.modules.includes("content") && s.mode !== "new") {
108
158
  s.theme = await ctx.ui.ask(
109
159
  "Brand theme id for this site (the client's theme in WMM's design system)",
110
160
  s.theme ?? s.siteKey,
package/cli/preflight.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ import { isOwnGitignore } from "./state.mjs";
4
5
 
5
6
  /**
6
7
  * Step 1 of the onboarding command: check the machine and the app BEFORE
@@ -11,6 +12,9 @@ import path from "node:path";
11
12
  /** @typedef {import("./run.mjs").Runner} Runner */
12
13
  /** @typedef {import("./ui.mjs").Ui} Ui */
13
14
 
15
+ /** Not content: git, a stopped run's progress, and files the operating system drops in folders. */
16
+ const IGNORED = new Set([".git", ".wmm-onboarding", ".DS_Store", "Thumbs.db", "desktop.ini"]);
17
+
14
18
  /** @param {string} version */
15
19
  function majorOf(version) {
16
20
  const m = /(\d+)/.exec(version);
@@ -18,8 +22,11 @@ function majorOf(version) {
18
22
  }
19
23
 
20
24
  /**
25
+ * `new`: an empty folder, where the site is created from WMM's template.
26
+ * `existing`: a Next.js app, wired in place.
27
+ *
21
28
  * @param {{ cwd: string, run: Runner, ui: Ui, resuming?: boolean }} ctx
22
- * @returns {Promise<{ mode: "existing", nextMajor: number | null }>}
29
+ * @returns {Promise<{ mode: "new" | "existing", nextMajor: number | null }>}
23
30
  */
24
31
  export async function preflight({ cwd, run, ui, resuming = false }) {
25
32
  const node = majorOf(process.versions.node) ?? 0;
@@ -29,13 +36,16 @@ export async function preflight({ cwd, run, ui, resuming = false }) {
29
36
 
30
37
  const pkgPath = path.join(cwd, "package.json");
31
38
  if (!existsSync(pkgPath)) {
32
- const entries = readdirSync(cwd).filter((f) => f !== ".git");
33
- if (entries.length === 0) {
34
- ui.stop(
35
- "This folder is empty, so it would be a new site from WMM's template. That path opens once the template is upgraded to Next.js 16; for now, run this inside an existing Next.js app.",
36
- );
39
+ // A stopped first run leaves its progress folder behind; that is still empty.
40
+ const entries = readdirSync(cwd).filter(
41
+ (f) => !IGNORED.has(f) && !(f === ".gitignore" && isOwnGitignore(cwd)),
42
+ );
43
+ if (entries.length > 0) {
44
+ ui.stop("Run this inside the app's folder (the one that has package.json), or in an empty folder to start a new site.");
37
45
  }
38
- ui.stop("Run this inside the app's folder: the one that has package.json.");
46
+ // A new site: no app and no repository yet, only the accounts that create them.
47
+ await signedIn(cwd, run, ui);
48
+ return { mode: "new", nextMajor: null };
39
49
  }
40
50
 
41
51
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
@@ -49,13 +59,7 @@ export async function preflight({ cwd, run, ui, resuming = false }) {
49
59
  );
50
60
  }
51
61
 
52
- const gh = await run("gh", ["auth", "status"], { cwd });
53
- if (gh.code === 127) ui.stop("The GitHub CLI is not installed.", "Install it from https://cli.github.com");
54
- if (gh.code !== 0) ui.stop("You are not signed in to GitHub.", "gh auth login");
55
-
56
- const vercel = await run("vercel", ["whoami"], { cwd });
57
- if (vercel.code === 127) ui.stop("The Vercel CLI is not installed.", "npm install -g vercel");
58
- if (vercel.code !== 0) ui.stop("You are not signed in to Vercel.", "vercel login");
62
+ await signedIn(cwd, run, ui);
59
63
 
60
64
  const git = await run("git", ["status", "--porcelain"], { cwd });
61
65
  if (git.code !== 0) ui.stop("This folder is not a git repository yet.", "git init");
@@ -67,12 +71,33 @@ export async function preflight({ cwd, run, ui, resuming = false }) {
67
71
  );
68
72
  }
69
73
 
74
+ return { mode: "existing", nextMajor };
75
+ }
76
+
77
+ /**
78
+ * GitHub and Vercel sign-ins (the command acts through them), plus a warning
79
+ * when Claude Code is missing.
80
+ *
81
+ * @param {string} cwd
82
+ * @param {Runner} run
83
+ * @param {Ui} ui
84
+ */
85
+ async function signedIn(cwd, run, ui) {
86
+ const git = await run("git", ["--version"], { cwd });
87
+ if (git.code === 127) ui.stop("Git is not installed.", "Install it from https://git-scm.com");
88
+
89
+ const gh = await run("gh", ["auth", "status"], { cwd });
90
+ if (gh.code === 127) ui.stop("The GitHub CLI is not installed.", "Install it from https://cli.github.com");
91
+ if (gh.code !== 0) ui.stop("You are not signed in to GitHub.", "gh auth login");
92
+
93
+ const vercel = await run("vercel", ["whoami"], { cwd });
94
+ if (vercel.code === 127) ui.stop("The Vercel CLI is not installed.", "npm install -g vercel");
95
+ if (vercel.code !== 0) ui.stop("You are not signed in to Vercel.", "vercel login");
96
+
70
97
  const claude = await run("claude", ["--version"], { cwd });
71
98
  if (claude.code !== 0) {
72
99
  ui.info(
73
- "Heads up: Claude Code is not installed. You will need it at step 7, where it helps wire your layout and choose the editable text. Install: https://claude.com/claude-code",
100
+ "Heads up: Claude Code is not installed. You will need it later, where it helps wire your layout and choose the editable text. Install: https://claude.com/claude-code",
74
101
  );
75
102
  }
76
-
77
- return { mode: "existing", nextMajor };
78
103
  }
@@ -48,14 +48,15 @@ export function cliProvisioner({ cwd, run, scope }) {
48
48
 
49
49
  return {
50
50
  async createRepoFromTemplate(name) {
51
+ // No --clone: it would clone into ./<name>, and this folder already holds
52
+ // the command's progress. The command pulls the repository in itself.
51
53
  await must("gh", [
52
54
  "repo",
53
55
  "create",
54
56
  `Web-My-Money/${name}`,
55
57
  "--private",
56
58
  "--template",
57
- "Web-My-Money/wmm-app-template",
58
- "--clone",
59
+ "Web-My-Money/wmm-site-template",
59
60
  ]);
60
61
  },
61
62
  async createRepoFromSource(name) {
@@ -0,0 +1,155 @@
1
+ // @ts-check
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { isOwnGitignore } from "./state.mjs";
5
+
6
+ /**
7
+ * A new site from WMM's template (spec 2026-10-09 §3.2): pull the repository
8
+ * GitHub created from `Web-My-Money/wmm-site-template` into the developer's
9
+ * folder, then make it this site's.
10
+ */
11
+
12
+ /** @typedef {import("./run.mjs").Runner} Runner */
13
+
14
+ /** The only two strings in the template that become this site's (see its README). */
15
+ export const MARKERS = { key: "wmm-template-site", name: "WMM Template Site" };
16
+
17
+ const FETCH_TRIES = 10;
18
+ const FETCH_WAIT_MS = 3000;
19
+
20
+ /**
21
+ * GitHub copies a template into the new repository in the background, so for a
22
+ * few seconds the repository has no `main`. That case is waited out; any other
23
+ * failure (usually git not signed in to GitHub) stops at once with the fix.
24
+ *
25
+ * @param {{ cwd: string, run: Runner, sleep: (ms: number) => Promise<void>, name: string }} ctx
26
+ * @returns {Promise<{ ok: true } | { ok: false, message: string, nextCommand?: string }>}
27
+ */
28
+ export async function pullTemplateRepo({ cwd, run, sleep, name }) {
29
+ const url = `https://github.com/Web-My-Money/${name}.git`;
30
+ if (!existsSync(path.join(cwd, ".git"))) {
31
+ const init = await run("git", ["init", "-b", "main"], { cwd });
32
+ if (init.code !== 0) return { ok: false, message: `git could not start a repository here: ${lastLine(init)}` };
33
+ }
34
+ const origin = await run("git", ["remote", "get-url", "origin"], { cwd });
35
+ if (origin.code !== 0) {
36
+ const add = await run("git", ["remote", "add", "origin", url], { cwd });
37
+ if (add.code !== 0) return { ok: false, message: `git could not add the new repository: ${lastLine(add)}` };
38
+ } else {
39
+ // A folder already tied to some other repository would fetch the wrong one forever.
40
+ const current = origin.stdout.trim();
41
+ const ours = new RegExp(`github\\.com[/:]Web-My-Money/${name}(\\.git)?$`, "i");
42
+ if (!ours.test(current)) {
43
+ return {
44
+ ok: false,
45
+ message: `This folder's git remote points at ${current}, not at Web-My-Money/${name}. Point it at the new site's repository, then run this again.`,
46
+ nextCommand: `git remote set-url origin ${url}`,
47
+ };
48
+ }
49
+ }
50
+
51
+ for (let i = 0; i < FETCH_TRIES; i++) {
52
+ const r = await run("git", ["fetch", "origin", "main"], { cwd });
53
+ if (r.code === 0) {
54
+ // The progress file's one-line .gitignore would block checking out the
55
+ // template's own (which already ignores the progress folder).
56
+ if (isOwnGitignore(cwd)) rmSync(path.join(cwd, ".gitignore"));
57
+ const co = await run("git", ["checkout", "-B", "main", "origin/main"], { cwd });
58
+ if (co.code !== 0) return { ok: false, message: `git could not check out the new site: ${lastLine(co)}` };
59
+ return { ok: true };
60
+ }
61
+ if (!/couldn't find remote ref/i.test(r.stderr)) {
62
+ return {
63
+ ok: false,
64
+ message: `git could not download the new repository: ${lastLine(r)}. If git is not signed in to GitHub, this fixes it; then run this again.`,
65
+ nextCommand: "gh auth setup-git",
66
+ };
67
+ }
68
+ if (i < FETCH_TRIES - 1) await sleep(FETCH_WAIT_MS);
69
+ }
70
+ return { ok: false, message: "GitHub is still preparing the repository. Run this again in a minute; it continues from here." };
71
+ }
72
+
73
+ // The same rule and reserved ids as defineSiteBrand (src/brand/index.ts): in a
74
+ // template site the key IS the theme id, so it must be a valid client theme.
75
+ const THEME_ID = /^[a-z][a-z0-9-]{0,40}$/;
76
+ const WMM_THEMES = ["wmm", "site", "light", "dark"];
77
+
78
+ /**
79
+ * Why a site key cannot be used for a site made from the template, or null.
80
+ * Checked before anything is created, so a bad key never leaves a repository behind.
81
+ *
82
+ * @param {string} siteKey
83
+ * @returns {string | null}
84
+ */
85
+ export function templateKeyProblem(siteKey) {
86
+ if (THEME_ID.test(siteKey) && !WMM_THEMES.includes(siteKey)) return null;
87
+ return `The site key "${siteKey}" cannot be used for a site made from WMM's template: it becomes the brand theme id, so it must start with a letter, use only lowercase letters, digits and dashes, be at most 41 characters, and not be one of ${WMM_THEMES.join(", ")}. Add the site again in Studio with a key like that.`;
88
+ }
89
+
90
+ /** @param {{ stdout: string, stderr: string }} r */
91
+ function lastLine(r) {
92
+ return (r.stderr || r.stdout).trim().split(/\r?\n/).slice(-1)[0] ?? "";
93
+ }
94
+
95
+ /**
96
+ * Replace the template's markers in every tracked file, escaped for where they
97
+ * land, and swap the template's README for one about this site.
98
+ *
99
+ * @param {{ cwd: string, run: Runner, siteKey: string, siteName: string }} ctx
100
+ * @returns {Promise<string[]>} the files that changed
101
+ */
102
+ export async function personalise({ cwd, run, siteKey, siteName }) {
103
+ const problem = templateKeyProblem(siteKey);
104
+ if (problem) throw new Error(problem);
105
+ const listed = await run("git", ["ls-files"], { cwd });
106
+ const files = listed.stdout.split(/\r?\n/).map((f) => f.trim()).filter(Boolean);
107
+
108
+ /** @type {string[]} */
109
+ const changed = [];
110
+ for (const rel of files) {
111
+ const abs = path.join(cwd, rel);
112
+ if (!existsSync(abs)) continue;
113
+ if (rel === "README.md") {
114
+ writeFileSync(abs, siteReadme(siteName));
115
+ changed.push(rel);
116
+ continue;
117
+ }
118
+ const body = readFileSync(abs, "utf8");
119
+ if (body.includes("\0")) continue; // binary
120
+ if (!body.includes(MARKERS.key) && !body.includes(MARKERS.name)) continue;
121
+ const next = body.replaceAll(MARKERS.key, siteKey).replaceAll(MARKERS.name, nameFor(rel, siteName));
122
+ writeFileSync(abs, next);
123
+ changed.push(rel);
124
+ }
125
+ return changed;
126
+ }
127
+
128
+ /**
129
+ * The display name, escaped for the file it lands in: inside a JSON or code
130
+ * string it must not end the string; inside a CSS comment it must not end the
131
+ * comment.
132
+ *
133
+ * @param {string} rel
134
+ * @param {string} name
135
+ */
136
+ function nameFor(rel, name) {
137
+ if (/\.(json|[cm]?[jt]sx?)$/.test(rel)) return JSON.stringify(name).slice(1, -1);
138
+ if (rel.endsWith(".css")) return name.replaceAll("*/", "");
139
+ return name;
140
+ }
141
+
142
+ /** @param {string} name */
143
+ function siteReadme(name) {
144
+ return `# ${name}
145
+
146
+ ${name}'s website, built from WMM's site template and connected to WMM Studio.
147
+
148
+ - Copy: \`dictionaries/en.json\` and \`es.json\` (same keys in both).
149
+ - Brand colours: \`app/client-theme.css\`.
150
+ - What the client can edit in Studio: \`lib/content-manifest.ts\`.
151
+ - Rules for working on this site: \`CLAUDE.md\`.
152
+
153
+ Before every push: \`npm run verify\`.
154
+ `;
155
+ }
package/cli/state.mjs CHANGED
@@ -10,12 +10,24 @@ import path from "node:path";
10
10
  * @typedef {{
11
11
  * siteKey: string, siteName: string, studioUrl: string, manifestUrl: string,
12
12
  * revalidateUrl: string, modules: string[], readyToken: string, done: string[],
13
- * theme?: string, scope?: string, baseBranch?: string, siteHasKey?: boolean
13
+ * theme?: string, scope?: string, baseBranch?: string, siteHasKey?: boolean,
14
+ * mode?: "new" | "existing", repoCreated?: boolean
14
15
  * }} OnboardingState
15
16
  */
16
17
 
17
18
  const DIR = ".wmm-onboarding";
18
19
 
20
+ /**
21
+ * True when `.gitignore` is the one-line file saveState wrote into a folder that
22
+ * had none: the command's own file, safe to treat as absent.
23
+ *
24
+ * @param {string} cwd
25
+ */
26
+ export function isOwnGitignore(cwd) {
27
+ const file = path.join(cwd, ".gitignore");
28
+ return existsSync(file) && readFileSync(file, "utf8").trim() === `${DIR}/`;
29
+ }
30
+
19
31
  /** @param {string} cwd @returns {OnboardingState | null} */
20
32
  export function loadState(cwd) {
21
33
  const file = path.join(cwd, DIR, "state.json");
package/package.json CHANGED
@@ -1,33 +1,33 @@
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
- }
1
+ {
2
+ "name": "@web-my-money/studio-consumer",
3
+ "version": "2.4.1",
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
+ }
@@ -1,96 +1,125 @@
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
+ ---
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. For a new site made from WMM's template, sets the client's colours and first copy instead. 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
+ ## 0. A new site from WMM's template (`"mode": "new"` in state.json)
25
+
26
+ The site was just created from `Web-My-Money/wmm-site-template`, so sections 1 and 2 are
27
+ already done: the layout (`app/[lang]/layout.tsx`) has the brand attributes, the editor
28
+ overlay and analytics, and `proxy.ts` sets the visitor cookie. Check they are there, say so in
29
+ one line, and skip to the client's details. Read `CLAUDE.md` for the site's rules.
30
+
31
+ 1. Ask the developer, one question at a time:
32
+ - the business name exactly as the client writes it, and one sentence on what they do and
33
+ for whom;
34
+ - the brand's primary and accent colours as hex codes (from the client's logo or brand
35
+ guide; "not yet" is a fine answer: leave the magenta placeholder and say so);
36
+ - which language visitors should land in by default (English or Spanish);
37
+ - where the main button should go (the client's booking calendar or form link), if known.
38
+ 2. Then propose, as one diff per file, and write only after a yes:
39
+ - `app/client-theme.css`: the colours (`--primary`, `--accent`, and a readable
40
+ `--primary-foreground`: white on a dark primary, near-black on a light one);
41
+ - `dictionaries/en.json` and `es.json`: `meta`, `hero` and `services` from what the
42
+ developer told you, in both languages (Spanish neutral, no regional slang). Keep every
43
+ key; the two files must keep identical keys;
44
+ - `lib/i18n.ts`: `DEFAULT_LOCALE`, only if it changes;
45
+ - the main button's link in `app/[lang]/page.tsx` (the `TODO` on the closing banner).
46
+ 3. **Never invent reviews, results, prices or credentials.** Leave the review placeholders
47
+ and the FAQ answers you do not know, and list them as "still needed from the client".
48
+ 4. Section 3 below is a review here, not a rebuild: `lib/content-manifest.ts` already makes
49
+ the home page's marketing text editable. Show the list of slots, ask whether anything should
50
+ be added or removed, and change it only on a yes.
51
+ 5. Go to section 4.
52
+
53
+ ## 1. Root layout (`app/layout.tsx`, or the layout that renders `<html>`)
54
+
55
+ - **Brand (content):** `import { brandRootAttributes } from "@web-my-money/studio-consumer/brand";`
56
+ and `import { brand } from "<relative path>/lib/brand";`, then spread
57
+ `{...brandRootAttributes(brand)}` on `<html>`. Keep every attribute already there.
58
+ - **Click-to-edit (content):** render `<WmmEditOverlay studioOrigin={process.env.NEXT_PUBLIC_STUDIO_ORIGIN} />`
59
+ from `@web-my-money/studio-consumer/content` once, inside `<body>`.
60
+ - **Analytics (analytics):** render `<FunnelAnalytics />` and `<EngagementTracking />` from
61
+ `@web-my-money/studio-consumer/analytics` once, inside `<body>`.
62
+ - **Overrides (content):** wherever the app loads its dictionary for a locale (often a
63
+ `[lang]` layout or a `getDictionary` helper), pass it through
64
+ `await applyDictOverrides(dict, locale)` from `lib/content.ts` before rendering. This is
65
+ what makes an edit in Studio appear on the page; without it the checklist goes green
66
+ and edits never show. If the app has no dictionary, say so and use `getLocalizedSlot`
67
+ for each editable value in step 3 instead.
68
+
69
+ ## 2. Proxy / middleware (analytics)
70
+
71
+ Studio's analytics and A/B testing need the `wmm_vid` visitor cookie, which
72
+ `withVisitorCookie` from `@web-my-money/studio-consumer/analytics` sets.
73
+
74
+ - Next 16 uses `proxy.ts` (older apps: `middleware.ts`), at the code root.
75
+ - **None exists:** create `proxy.ts` that returns `withVisitorCookie(request)`, with a
76
+ matcher that skips `_next`, `api` and static files.
77
+ - **One exists:** compose, never replace. Call `withVisitorCookie(request)` for the
78
+ pass-through case, and keep every existing redirect and rewrite exactly as it is. If the
79
+ existing code returns its own `NextResponse`, show the developer the two options (set
80
+ the cookie on that response, or call `withVisitorCookie` first) and let them choose.
81
+
82
+ ## 3. Choose the editable text (content)
83
+
84
+ This is the real decision in the whole onboarding. **Do not decide it alone.**
85
+
86
+ 1. Find the copy: the per-locale dictionary (e.g. `dictionaries/en.json`, `es.json`), or,
87
+ if there is none, the text in the page components.
88
+ 2. Propose a table grouped by page and section, one row per candidate:
89
+
90
+ | Key | Current text (EN) | Editable? | Why |
91
+ |---|---|---|---|
92
+
93
+ Keys are `page.section.thing` (e.g. `home.hero.headline`). Give a one-line reason for
94
+ **every "no"**. Lean towards "no" for, and always justify:
95
+ - legal and compliance text (privacy, terms, disclaimers, medical or financial claims);
96
+ - form field names, validation and error messages;
97
+ - analytics, tracking and pixel ids;
98
+ - icons and component references (never editable);
99
+ - anything the code uses as an identifier, a route or a CSS class.
100
+ Lean towards "yes" for headlines, sub-headlines, body copy, calls to action, testimonials
101
+ and images a marketer would change.
102
+ 3. Wait for the developer to approve or edit the table.
103
+ 4. Then fill in `lib/content-manifest.ts`, keeping its `getManifest()` shape and the
104
+ `siteKey: SITE_KEY` it returns:
105
+ - a slot only for text the code actually renders;
106
+ - `type`: `text` (or `richtext`, `image`, `list`, `select` where that fits);
107
+ - `dictPath` for dictionary text (the override is merged into the dictionary, no
108
+ component changes needed); no `dictPath` for images or other non-dictionary values,
109
+ which are read with `getSlot` and passed down as props;
110
+ - `default` is read from the dictionary (`en`/`es` imports), **never retyped**, so it
111
+ cannot go stale;
112
+ - `label` in plain words a client understands ("Home: hero headline");
113
+ - `group` per page section, `sortOrder` in reading order.
114
+
115
+ If `lib/content-manifest.ts` already had real slots before `init` ran, keep them and only
116
+ add what the developer approves.
117
+
118
+ ## 4. Check, then hand back
119
+
120
+ Run `npm run verify` (or the app's equivalent: typecheck, lint, test, build). Fix what it
121
+ reports. If the app has no test runner and `tests/studio-frame-ancestors.test.ts` was not
122
+ written, offer to add Vitest and that test.
123
+
124
+ When everything passes, tell the developer in one sentence what changed, and that they can
125
+ close Claude Code (type `/exit`) so the `init` command continues with the next step.
@@ -75,7 +75,7 @@ function armFor(path: string): string | undefined {
75
75
  try {
76
76
  const { variant, experimentKey } = resolveVariant(
77
77
  path,
78
- cookieVisitorId(),
78
+ serverSeenVisitorId(),
79
79
  runningExperiments,
80
80
  );
81
81
  return experimentKey ? variant : undefined;
@@ -268,17 +268,57 @@ function cookieVisitorId(): string | null {
268
268
  }
269
269
  }
270
270
 
271
+ /**
272
+ * Mirror the visitor id into the `wmm_vid` cookie.
273
+ *
274
+ * The proxy only mints this cookie on routes that can run an A/B test, so that
275
+ * the rest of the site stays cacheable. The collector still needs it everywhere
276
+ * — it stamps `visitorId` on an event from the cookie and from nothing else, and
277
+ * an event without one drops out of the comparison — and a visitor who lands on a
278
+ * static page first must keep the same identity when they later reach /med-spa,
279
+ * where the server reads the cookie. Writing it here, before the first event is
280
+ * built, covers both: the id is the one already in storage, so it never changes.
281
+ *
282
+ * Same attributes the proxy uses, for the same reasons: first-party, readable by
283
+ * the browser, no PII, the maximum lifetime Chrome honours.
284
+ */
285
+ function writeVisitorCookie(id: string): void {
286
+ try {
287
+ document.cookie =
288
+ `wmm_vid=${encodeURIComponent(id)}; Max-Age=${400 * 24 * 60 * 60}; Path=/; SameSite=Lax; Secure`;
289
+ } catch {
290
+ // Cookies blocked: events fall back to the storage id on the client, which is
291
+ // the pre-existing behaviour for a visitor the middleware never reached.
292
+ }
293
+ }
294
+
295
+ /**
296
+ * The cookie as it arrived with this document — the identity the server could
297
+ * have bucketed this render with.
298
+ *
299
+ * Captured on first use and never refreshed, because this module now writes the
300
+ * cookie itself (see writeVisitorCookie). Reading it live would make the arm
301
+ * label depend on a cookie the server never saw: a page the proxy did not stamp
302
+ * would get one written here and then be labelled with an arm nobody assigned.
303
+ * The label must only ever come from an id that was on the request.
304
+ */
305
+ let seenAtLoad: string | null | undefined;
306
+ function serverSeenVisitorId(): string | null {
307
+ if (seenAtLoad === undefined) seenAtLoad = cookieVisitorId();
308
+ return seenAtLoad;
309
+ }
310
+
271
311
  function visitorId(): string {
272
- const fromCookie = cookieVisitorId();
312
+ const fromCookie = serverSeenVisitorId();
273
313
  if (fromCookie) return fromCookie;
274
314
 
275
- // No cookie: middleware has not run for this document (a cached shell, a route
276
- // it does not match). Fall back to storage so events are still attributable.
277
- const existing = readStore(VISITOR_KEY);
278
- if (existing) return existing;
279
- const fresh = uuid();
280
- writeStore(VISITOR_KEY, fresh);
281
- return fresh;
315
+ // No cookie: this route is not one the proxy mints it on, or the visitor
316
+ // blocked it. Use the stored id, creating one on first sight, and publish it as
317
+ // the cookie so the server and the collector see the same identity.
318
+ const id = readStore(VISITOR_KEY) ?? uuid();
319
+ writeStore(VISITOR_KEY, id);
320
+ if (cookieVisitorId() !== id) writeVisitorCookie(id);
321
+ return id;
282
322
  }
283
323
 
284
324
  /**
@@ -1,37 +1,37 @@
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
+ /** Proves at runtime which build a consumer is actually running. */
2
+ export const PACKAGE_VERSION = "2.4.1";
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";
@@ -26,6 +26,14 @@ function newVisitorId(): string {
26
26
  export function withVisitorCookie(req: NextRequest): NextResponse {
27
27
  const { pathname } = req.nextUrl;
28
28
 
29
+ // A first-time visitor has no cookie yet. Mint the id here and put it on the
30
+ // REQUEST too, so this very render buckets them. Setting it only on the
31
+ // response rendered control on the first page view while the browser stamped
32
+ // the new id's arm on events, filing visitors who saw control under B.
33
+ const existingVisitorId = req.cookies.get(VISITOR_COOKIE)?.value;
34
+ const visitorId = existingVisitorId || newVisitorId();
35
+ if (!existingVisitorId) req.cookies.set(VISITOR_COOKIE, visitorId);
36
+
29
37
  // The path, as a request header.
30
38
  //
31
39
  // Server components cannot read their own pathname, and A/B assignment has to
@@ -71,10 +79,10 @@ export function withVisitorCookie(req: NextRequest): NextResponse {
71
79
  * Not httpOnly, on purpose: the collector reads it in the browser. It carries
72
80
  * no PII — it is a random id, exactly like the localStorage value it replaces.
73
81
  */
74
- if (!req.cookies.get(VISITOR_COOKIE)?.value) {
82
+ if (!existingVisitorId) {
75
83
  response.cookies.set({
76
84
  name: VISITOR_COOKIE,
77
- value: newVisitorId(),
85
+ value: visitorId,
78
86
  maxAge: VISITOR_COOKIE_MAX_AGE,
79
87
  path: "/",
80
88
  sameSite: "lax",
@@ -1,14 +1,14 @@
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
+ /** Proves at runtime which build a consumer is actually running. */
2
+ export const PACKAGE_VERSION = "2.4.1";
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.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
- }
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.4.1";
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,27 +1,27 @@
1
- /** Proves at runtime which build a consumer is actually running. */
2
- export const PACKAGE_VERSION = "2.3.0";
3
-
4
- export {
5
- createContentClient,
6
- type ContentClient,
7
- type ContentManifest,
8
- type ManifestSlot,
9
- type StudioForm,
10
- } from "./payload";
11
- export { createManifestHandler } from "./manifest-handler";
12
- export { createRevalidateHandler } from "./revalidate-handler";
13
- export { studioFrameAncestors } from "./headers";
14
- export {
15
- overrideValueForDict,
16
- resolveLocalized,
17
- setPath,
18
- withSlotOverride,
19
- } from "./dict-overrides";
20
- export {
21
- WmmEditOverlay,
22
- StudioSlotPreviewBridge,
23
- StudioFormPreviewBridge,
24
- type PreviewableSlot,
25
- type StudioPreviewFormProps,
26
- type StudioPreviewFormComponent,
27
- } from "./preview";
1
+ /** Proves at runtime which build a consumer is actually running. */
2
+ export const PACKAGE_VERSION = "2.4.1";
3
+
4
+ export {
5
+ createContentClient,
6
+ type ContentClient,
7
+ type ContentManifest,
8
+ type ManifestSlot,
9
+ type StudioForm,
10
+ } from "./payload";
11
+ export { createManifestHandler } from "./manifest-handler";
12
+ export { createRevalidateHandler } from "./revalidate-handler";
13
+ export { studioFrameAncestors } from "./headers";
14
+ export {
15
+ overrideValueForDict,
16
+ resolveLocalized,
17
+ setPath,
18
+ withSlotOverride,
19
+ } from "./dict-overrides";
20
+ export {
21
+ WmmEditOverlay,
22
+ StudioSlotPreviewBridge,
23
+ StudioFormPreviewBridge,
24
+ type PreviewableSlot,
25
+ type StudioPreviewFormProps,
26
+ type StudioPreviewFormComponent,
27
+ } from "./preview";
@@ -1,14 +1,14 @@
1
- /** Pure; safe in client components. */
2
- export const PACKAGE_VERSION = "2.3.0";
3
-
4
- /**
5
- * CSS `object-position` for a Studio image value's focal point (spec §6.2).
6
- * Anything missing or out of range reads as the centre — the browser default —
7
- * so an image without a focal point renders exactly as before.
8
- */
9
- export function focalObjectPosition(value: unknown): string {
10
- const f = (value as { focal?: { x?: unknown; y?: unknown } } | null | undefined)?.focal;
11
- const ok = (n: unknown): n is number => typeof n === "number" && n >= 0 && n <= 1;
12
- if (!f || !ok(f.x) || !ok(f.y)) return "50% 50%";
13
- return `${Math.round(f.x * 100)}% ${Math.round(f.y * 100)}%`;
14
- }
1
+ /** Pure; safe in client components. */
2
+ export const PACKAGE_VERSION = "2.4.1";
3
+
4
+ /**
5
+ * CSS `object-position` for a Studio image value's focal point (spec §6.2).
6
+ * Anything missing or out of range reads as the centre — the browser default —
7
+ * so an image without a focal point renders exactly as before.
8
+ */
9
+ export function focalObjectPosition(value: unknown): string {
10
+ const f = (value as { focal?: { x?: unknown; y?: unknown } } | null | undefined)?.focal;
11
+ const ok = (n: unknown): n is number => typeof n === "number" && n >= 0 && n <= 1;
12
+ if (!f || !ok(f.x) || !ok(f.y)) return "50% 50%";
13
+ return `${Math.round(f.x * 100)}% ${Math.round(f.y * 100)}%`;
14
+ }
@@ -1,13 +1,13 @@
1
- // Hand-maintained alongside index.mjs.
2
-
3
- type HeaderRule = { source: string; headers: { key: string; value: string }[] };
4
-
5
- /**
6
- * Wrap a site's next.config so Studio can edit it: transpiles this package,
7
- * sets `frame-ancestors 'self' <studio>` (spliced into an existing CSP), and
8
- * refuses X-Frame-Options. Wrapping twice is the same as once.
9
- */
10
- export declare function withStudio<T extends object>(
11
- config: T,
12
- opts?: { studioOrigins?: string[] },
13
- ): T & { transpilePackages: string[]; headers: () => Promise<HeaderRule[]> };
1
+ // Hand-maintained alongside index.mjs.
2
+
3
+ type HeaderRule = { source: string; headers: { key: string; value: string }[] };
4
+
5
+ /**
6
+ * Wrap a site's next.config so Studio can edit it: transpiles this package,
7
+ * sets `frame-ancestors 'self' <studio>` (spliced into an existing CSP), and
8
+ * refuses X-Frame-Options. Wrapping twice is the same as once.
9
+ */
10
+ export declare function withStudio<T extends object>(
11
+ config: T,
12
+ opts?: { studioOrigins?: string[] },
13
+ ): T & { transpilePackages: string[]; headers: () => Promise<HeaderRule[]> };