@diviops/mcp-server 1.5.11 → 1.5.12

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
@@ -85,18 +85,31 @@ See [server-reference.md](../docs/server-reference.md) for per-tool descriptions
85
85
  The package also ships a standalone command-line preset emitter, `diviops-preset`,
86
86
  that produces byte-canonical Divi 5.5.x preset JSON gated by the verified-attrs
87
87
  registry (`data/verified-attrs.json`). It is independent of the MCP stdio server —
88
- run it directly:
88
+ run it directly. Current commands:
89
+
90
+ | Command | Emits |
91
+ |---|---|
92
+ | `diviops-preset button [options]` | `divi/button` group preset |
93
+ | `diviops-preset heading-font [options]` | `divi/font` group preset for `divi/heading` (Pattern A — Google Fonts — or Pattern B — local-hosted) |
89
94
 
90
95
  ```bash
91
96
  diviops-preset button --name "Primary" --bg-color gcid-primary-color \
92
97
  --bg-color-hover gcid-secondary-color --radius 8px \
93
98
  --font-family Inter --font-weight 600 --font-color gcid-body-color
99
+
100
+ diviops-preset heading-font --name "Heading H1" --pattern google \
101
+ --font-family Inter --font-weight 700 \
102
+ --font-color gcid-heading-color --font-size 48px
94
103
  ```
95
104
 
96
105
  `--dry-run` (the default) composes and prints the canonical JSON with no
97
106
  credentials and no network. `--apply` posts to the existing `/preset/create`
98
107
  REST route, reusing the same `WP_URL` / `WP_USER` / `WP_APP_PASSWORD` env vars.
99
- The current scope is one emitter — `divi/button` group presets. See the
108
+
109
+ The CLI's coverage is intentionally narrow: only the (module, group, variant)
110
+ combinations whose canonical shape is VB-verified in the registry are
111
+ emittable. It is **not** an all-module or all-font-family emitter — each
112
+ additional vertical slice lands with its own verified evidence. See the
100
113
  [preset-cli reference](https://github.com/oaris-dev/diviops/blob/main/diviops-server/src/preset-cli/README.md)
101
114
  for the full command reference (the `src/` tree is not part of the published
102
115
  npm package — this link resolves on the repository).
@@ -39,6 +39,14 @@ test("unknown command exits 1 (invalid input)", async () => {
39
39
  assert.equal(code, EXIT.INVALID_INPUT);
40
40
  assert.match(io.stderr.join("\n"), /Unknown command/);
41
41
  });
42
+ test("--help advertises the heading-font command", async () => {
43
+ const io = capture();
44
+ const code = await run(["--help"], io);
45
+ assert.equal(code, EXIT.OK);
46
+ const help = io.stdout.join("\n");
47
+ assert.match(help, /heading-font/);
48
+ assert.match(help, /--pattern <google\|local>/);
49
+ });
42
50
  test("unknown flag exits 1 (invalid input)", async () => {
43
51
  const io = capture();
44
52
  const code = await run(["button", "--name", "X", "--bogus", "y"], io);
@@ -138,6 +146,179 @@ test("parseArgs: --radius-sync rejects values outside on|off", () => {
138
146
  const parsed = parseArgs(["button", "--name", "P", "--radius-sync", "maybe"]);
139
147
  assert.throws(() => buildButtonInput(parsed), /radius-sync must be/);
140
148
  });
149
+ // ------------------------------------------------------------------
150
+ // heading-font command — CLI integration (parse → emit → dry-run JSON)
151
+ // ------------------------------------------------------------------
152
+ test("heading-font dry-run (Pattern A) emits canonical JSON, exit 0", async () => {
153
+ const io = capture();
154
+ const code = await run([
155
+ "heading-font",
156
+ "--name",
157
+ "H1",
158
+ "--pattern",
159
+ "google",
160
+ "--font-family",
161
+ "Inter",
162
+ "--font-weight",
163
+ "700",
164
+ "--font-color",
165
+ "gcid-heading-color",
166
+ "--font-size",
167
+ "48px",
168
+ ], io);
169
+ assert.equal(code, EXIT.OK);
170
+ const parsed = JSON.parse(io.stdout.join("\n"));
171
+ assert.equal(parsed.type, "group");
172
+ assert.equal(parsed.dry_run, true);
173
+ assert.equal(parsed.module_name, "divi/heading");
174
+ assert.equal(parsed.group_name, "divi/font");
175
+ assert.equal(parsed.group_id, "designTitleText");
176
+ const value = parsed.attrs.title.decoration.font.font.desktop.value;
177
+ assert.equal(value.family, "Inter");
178
+ assert.equal(value.weight, "700");
179
+ assert.equal(value.size, "48px");
180
+ assert.equal(value.color, '$variable({"type":"color","value":{"name":"gcid-heading-color","settings":{}}})$');
181
+ });
182
+ test("heading-font dry-run (Pattern B) emits no `weight` key", async () => {
183
+ const io = capture();
184
+ const code = await run([
185
+ "heading-font",
186
+ "--name",
187
+ "H1-local",
188
+ "--pattern",
189
+ "local",
190
+ "--font-family",
191
+ "Sora 700",
192
+ "--font-color",
193
+ "gcid-heading-color",
194
+ "--font-size",
195
+ "48px",
196
+ ], io);
197
+ assert.equal(code, EXIT.OK);
198
+ const parsed = JSON.parse(io.stdout.join("\n"));
199
+ const value = parsed.attrs.title.decoration.font.font.desktop.value;
200
+ assert.equal(value.family, "Sora 700");
201
+ assert.equal("weight" in value, false, "Pattern B emits no weight key");
202
+ });
203
+ test("heading-font without --pattern exits 1 (invalid input)", async () => {
204
+ const io = capture();
205
+ const code = await run([
206
+ "heading-font",
207
+ "--name",
208
+ "H1",
209
+ "--font-family",
210
+ "Inter",
211
+ "--font-weight",
212
+ "700",
213
+ ], io);
214
+ assert.equal(code, EXIT.INVALID_INPUT);
215
+ assert.match(io.stderr.join("\n"), /--pattern/);
216
+ assert.match(io.stderr.join("\n"), /google\|local/);
217
+ });
218
+ test("heading-font without --name exits 1", async () => {
219
+ const io = capture();
220
+ const code = await run(["heading-font", "--pattern", "google", "--font-family", "Inter"], io);
221
+ assert.equal(code, EXIT.INVALID_INPUT);
222
+ assert.match(io.stderr.join("\n"), /requires --name/);
223
+ });
224
+ test("heading-font --pattern local + --font-weight is refused (exit 1)", async () => {
225
+ const io = capture();
226
+ const code = await run([
227
+ "heading-font",
228
+ "--name",
229
+ "H1-bad",
230
+ "--pattern",
231
+ "local",
232
+ "--font-family",
233
+ "Sora 700",
234
+ "--font-weight",
235
+ "700",
236
+ ], io);
237
+ assert.equal(code, EXIT.INVALID_INPUT);
238
+ assert.match(io.stderr.join("\n"), /Pattern B/);
239
+ });
240
+ test("heading-font --pattern with an invalid value exits 1", async () => {
241
+ const io = capture();
242
+ const code = await run([
243
+ "heading-font",
244
+ "--name",
245
+ "H1",
246
+ "--pattern",
247
+ "auto",
248
+ "--font-family",
249
+ "Inter",
250
+ ], io);
251
+ assert.equal(code, EXIT.INVALID_INPUT);
252
+ assert.match(io.stderr.join("\n"), /--pattern must be/);
253
+ });
254
+ test("heading-font dry-run requires no credentials and no network", async () => {
255
+ // Mirrors the dry-run-no-creds button assertion: a heading-font dry-run
256
+ // must not throw a CredentialsMissingError. Exercises AC: --apply is
257
+ // the only path that touches credentials/handshake/network.
258
+ const saved = {
259
+ WP_URL: process.env.WP_URL,
260
+ WP_USER: process.env.WP_USER,
261
+ WP_APP_PASSWORD: process.env.WP_APP_PASSWORD,
262
+ };
263
+ delete process.env.WP_URL;
264
+ delete process.env.WP_USER;
265
+ delete process.env.WP_APP_PASSWORD;
266
+ try {
267
+ const io = capture();
268
+ const code = await run([
269
+ "heading-font",
270
+ "--name",
271
+ "H1",
272
+ "--pattern",
273
+ "google",
274
+ "--font-family",
275
+ "Inter",
276
+ ], io);
277
+ assert.equal(code, EXIT.OK);
278
+ assert.equal(io.stderr.length, 0, "no error output on credential-free dry-run");
279
+ }
280
+ finally {
281
+ if (saved.WP_URL !== undefined)
282
+ process.env.WP_URL = saved.WP_URL;
283
+ if (saved.WP_USER !== undefined)
284
+ process.env.WP_USER = saved.WP_USER;
285
+ if (saved.WP_APP_PASSWORD !== undefined)
286
+ process.env.WP_APP_PASSWORD = saved.WP_APP_PASSWORD;
287
+ }
288
+ });
289
+ test("heading-font --apply without credentials exits 1 with a credentials hint", async () => {
290
+ const saved = {
291
+ WP_URL: process.env.WP_URL,
292
+ WP_USER: process.env.WP_USER,
293
+ WP_APP_PASSWORD: process.env.WP_APP_PASSWORD,
294
+ };
295
+ delete process.env.WP_URL;
296
+ delete process.env.WP_USER;
297
+ delete process.env.WP_APP_PASSWORD;
298
+ try {
299
+ const io = capture();
300
+ const code = await run([
301
+ "heading-font",
302
+ "--name",
303
+ "H1",
304
+ "--pattern",
305
+ "google",
306
+ "--font-family",
307
+ "Inter",
308
+ "--apply",
309
+ ], io);
310
+ assert.equal(code, EXIT.INVALID_INPUT);
311
+ assert.match(io.stderr.join("\n"), /requires WordPress credentials/);
312
+ }
313
+ finally {
314
+ if (saved.WP_URL !== undefined)
315
+ process.env.WP_URL = saved.WP_URL;
316
+ if (saved.WP_USER !== undefined)
317
+ process.env.WP_USER = saved.WP_USER;
318
+ if (saved.WP_APP_PASSWORD !== undefined)
319
+ process.env.WP_APP_PASSWORD = saved.WP_APP_PASSWORD;
320
+ }
321
+ });
141
322
  test("dry-run output includes the bypass corner when requested", async () => {
142
323
  const io = capture();
143
324
  const code = await run(["button", "--name", "P", "--bg-color", "#111", "--bypass-hover-padding-gate"], io);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `divi/font` heading emitter shape + gating coverage:
3
+ * - fixture-based shape assertion against round-1a (Pattern A) and
4
+ * round-1b (Pattern B) canonical captures;
5
+ * - Pattern B no-weight discriminator: omitted `weight` produces no key;
6
+ * - Pattern B + explicit weight refused as unverified/out-of-scope;
7
+ * - variant-aware registry gating: missing applicability or under-
8
+ * verified evidence on the chosen variant throws with attr / pattern /
9
+ * effective-level / source in the message; Pattern A evidence does NOT
10
+ * vouch for Pattern B and vice versa.
11
+ */
12
+ export {};
@@ -0,0 +1,249 @@
1
+ /**
2
+ * `divi/font` heading emitter shape + gating coverage:
3
+ * - fixture-based shape assertion against round-1a (Pattern A) and
4
+ * round-1b (Pattern B) canonical captures;
5
+ * - Pattern B no-weight discriminator: omitted `weight` produces no key;
6
+ * - Pattern B + explicit weight refused as unverified/out-of-scope;
7
+ * - variant-aware registry gating: missing applicability or under-
8
+ * verified evidence on the chosen variant throws with attr / pattern /
9
+ * effective-level / source in the message; Pattern A evidence does NOT
10
+ * vouch for Pattern B and vice versa.
11
+ */
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { readFileSync } from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
+ import { dirname, join } from "node:path";
17
+ import { emitHeadingFontGroupPreset, composeHeadingFontAttrs, buildHeadingFontPresetCreateBody, UnsupportedVariantCombinationError, HEADING_FONT_MODULE, HEADING_FONT_GROUP_NAME, HEADING_FONT_GROUP_ID, HEADING_FONT_PATTERN_VARIANTS, } from "../heading-font-emitter.js";
18
+ import { loadRegistry, EvidenceGateError, } from "../registry.js";
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const REPO_ROOT = join(__dirname, "..", "..", "..", "..");
21
+ const FIXTURE_DIR = join(REPO_ROOT, "docs/verification/evidence/canonical-shape-dumps-2026-05-18");
22
+ const FIXTURE_1A = join(FIXTURE_DIR, "round-1a-heading-h1-pattern-a-google.json");
23
+ const FIXTURE_1B = join(FIXTURE_DIR, "round-1b-heading-h1-pattern-b-local.json");
24
+ const registry = loadRegistry();
25
+ test("emitter byte-matches round-1a (Pattern A — Google Fonts) fixture attrs", () => {
26
+ const fixture = JSON.parse(readFileSync(FIXTURE_1A, "utf8"));
27
+ const canonicalAttrs = fixture.preset_entry.attrs;
28
+ const canonicalStyleAttrs = fixture.preset_entry.styleAttrs;
29
+ const entry = emitHeadingFontGroupPreset({
30
+ name: "canonical-heading-h1-vb-google-2026-05-18",
31
+ pattern: "google",
32
+ family: "Inter",
33
+ weight: "700",
34
+ color: '$variable({"type":"color","value":{"name":"gcid-heading-color","settings":{}}})$',
35
+ size: "48px",
36
+ }, registry);
37
+ assert.deepEqual(entry.attrs, canonicalAttrs, "emitted attrs must byte-match the Pattern A canonical capture");
38
+ // Round 1a observation: attrs and styleAttrs are byte-identical. The
39
+ // emitter only emits `attrs`; the plugin's /preset/create route mirrors
40
+ // into styleAttrs. So `attrs` must equal the fixture's styleAttrs too.
41
+ assert.deepEqual(entry.attrs, canonicalStyleAttrs, "attrs must equal styleAttrs (mirror at /preset/create layer)");
42
+ assert.equal(entry.type, "group");
43
+ assert.equal(entry.group_name, HEADING_FONT_GROUP_NAME);
44
+ assert.equal(entry.group_id, HEADING_FONT_GROUP_ID);
45
+ assert.equal(entry.module_name, HEADING_FONT_MODULE);
46
+ assert.equal(entry.pattern_variant, HEADING_FONT_PATTERN_VARIANTS.google);
47
+ });
48
+ test("emitter byte-matches round-1b (Pattern B — local-hosted) fixture attrs", () => {
49
+ const fixture = JSON.parse(readFileSync(FIXTURE_1B, "utf8"));
50
+ const canonicalAttrs = fixture.preset_entry.attrs;
51
+ const canonicalStyleAttrs = fixture.preset_entry.styleAttrs;
52
+ const entry = emitHeadingFontGroupPreset({
53
+ name: "canonical-heading-h1-vb-local-2026-05-18",
54
+ pattern: "local",
55
+ family: "Sora 700",
56
+ // weight intentionally absent — Pattern B encodes the weight in the
57
+ // family string itself.
58
+ color: '$variable({"type":"color","value":{"name":"gcid-heading-color","settings":{}}})$',
59
+ size: "48px",
60
+ }, registry);
61
+ assert.deepEqual(entry.attrs, canonicalAttrs, "emitted attrs must byte-match the Pattern B canonical capture");
62
+ assert.deepEqual(entry.attrs, canonicalStyleAttrs, "attrs must equal styleAttrs (mirror at /preset/create layer)");
63
+ assert.equal(entry.pattern_variant, HEADING_FONT_PATTERN_VARIANTS.local);
64
+ });
65
+ test("Pattern B discriminator: omitted weight produces NO weight key", () => {
66
+ const attrs = composeHeadingFontAttrs({
67
+ name: "B",
68
+ pattern: "local",
69
+ family: "Sora 700",
70
+ color: "#666666",
71
+ size: "48px",
72
+ });
73
+ const value = attrs.title.decoration.font.font.desktop.value;
74
+ assert.equal("weight" in value, false, "Pattern B canonical shape has no `weight` key in attrs.title.decoration.font.font.desktop.value");
75
+ // The exact set of structural keys, no more, no less. Order is not
76
+ // semantically meaningful (deep-equal is order-insensitive on objects)
77
+ // — sort here for a stable assertion.
78
+ assert.deepEqual(Object.keys(value).sort(), ["color", "family", "size"], "Pattern B emits ONLY the keys the user specified — no defaulted weight");
79
+ });
80
+ test("Pattern A discriminator: weight emitted when specified, absent when not", () => {
81
+ const withWeight = composeHeadingFontAttrs({
82
+ name: "A+w",
83
+ pattern: "google",
84
+ family: "Inter",
85
+ weight: "700",
86
+ });
87
+ const withoutWeight = composeHeadingFontAttrs({
88
+ name: "A-w",
89
+ pattern: "google",
90
+ family: "Inter",
91
+ });
92
+ const wv = withWeight.title.decoration.font.font.desktop.value;
93
+ const wov = withoutWeight.title.decoration.font.font.desktop.value;
94
+ assert.equal(wv.weight, "700");
95
+ assert.equal("weight" in wov, false, "weight absent when not specified");
96
+ });
97
+ test("Pattern B + explicit --font-weight is refused as out-of-scope", () => {
98
+ assert.throws(() => emitHeadingFontGroupPreset({
99
+ name: "B-with-weight",
100
+ pattern: "local",
101
+ family: "Sora 700",
102
+ weight: "700",
103
+ }, registry), (err) => {
104
+ assert.ok(err instanceof UnsupportedVariantCombinationError);
105
+ assert.match(err.message, /Pattern B/);
106
+ assert.match(err.message, /weight/);
107
+ return true;
108
+ });
109
+ });
110
+ test("emit-on-specification: only specified keys produce keys (Pattern A)", () => {
111
+ const attrs = composeHeadingFontAttrs({
112
+ name: "minimal",
113
+ pattern: "google",
114
+ family: "Inter",
115
+ });
116
+ const value = attrs.title.decoration.font.font.desktop.value;
117
+ assert.deepEqual(Object.keys(value), ["family"], "ONLY the touched key emitted — no defaulted weight/color/size/lineHeight");
118
+ });
119
+ test("variable color tokens get the {name,settings} object shape + trailing )$", () => {
120
+ const attrs = composeHeadingFontAttrs({
121
+ name: "v",
122
+ pattern: "google",
123
+ color: "gcid-heading-color",
124
+ });
125
+ const color = attrs.title.decoration.font.font.desktop.value.color;
126
+ assert.ok(color.endsWith(")$"));
127
+ assert.match(color, /^\$variable\(/);
128
+ const payload = JSON.parse(color.slice("$variable(".length, -2));
129
+ assert.deepEqual(payload, {
130
+ type: "color",
131
+ value: { name: "gcid-heading-color", settings: {} },
132
+ });
133
+ });
134
+ test("literal hex color is emitted verbatim, not wrapped", () => {
135
+ const attrs = composeHeadingFontAttrs({
136
+ name: "h",
137
+ pattern: "google",
138
+ color: "#666666",
139
+ });
140
+ assert.equal(attrs.title.decoration.font.font.desktop.value.color, "#666666");
141
+ });
142
+ test("lineHeight is emitted only when specified", () => {
143
+ const withLH = composeHeadingFontAttrs({
144
+ name: "lh+",
145
+ pattern: "google",
146
+ family: "Inter",
147
+ lineHeight: "1.1",
148
+ });
149
+ assert.equal(withLH.title.decoration.font.font.desktop.value.lineHeight, "1.1");
150
+ const without = composeHeadingFontAttrs({
151
+ name: "lh-",
152
+ pattern: "google",
153
+ family: "Inter",
154
+ });
155
+ assert.equal("lineHeight" in without.title.decoration.font.font.desktop.value, false);
156
+ });
157
+ test("emitter rejects an invalid `pattern` value", () => {
158
+ assert.throws(() => emitHeadingFontGroupPreset(
159
+ // @ts-expect-error — exercising runtime validation
160
+ { name: "x", pattern: "auto", family: "Inter" }, registry), /pattern.*google.*local/i);
161
+ });
162
+ test("emitter rejects an empty preset (no styling specified)", () => {
163
+ assert.throws(() => emitHeadingFontGroupPreset({ name: "empty", pattern: "google" }, registry), /empty preset/);
164
+ });
165
+ test("emitter rejects a missing name", () => {
166
+ assert.throws(() => emitHeadingFontGroupPreset({ name: "", pattern: "google", family: "Inter" }, registry), /requires a non-empty `name`/);
167
+ });
168
+ test("variant-aware gate: missing applicability throws with attr+variant+source", () => {
169
+ // Build a synthetic registry where the Pattern A entry's applicability
170
+ // for divi/heading is REMOVED — Pattern B still verified. Confirms a
171
+ // missing applicability cell on the chosen variant is caught at the
172
+ // gate (not silently inherited from the pattern level), and that the
173
+ // error message names the variant and the registry source.
174
+ const real = loadRegistry();
175
+ const synthetic = JSON.parse(JSON.stringify(real));
176
+ const t2 = synthetic.tier2 ?? [];
177
+ const a = t2.find((e) => e.pattern_family === "divi/font" &&
178
+ e.pattern_variant === HEADING_FONT_PATTERN_VARIANTS.google);
179
+ assert.ok(a, "test prereq: Pattern A entry must exist in the real registry");
180
+ // Strip the divi/heading applicability cell from the Pattern A variant.
181
+ delete a.applicability["divi/heading"];
182
+ assert.throws(() => emitHeadingFontGroupPreset({
183
+ name: "missing-applic",
184
+ pattern: "google",
185
+ family: "Inter",
186
+ weight: "700",
187
+ }, synthetic), (err) => {
188
+ assert.ok(err instanceof EvidenceGateError);
189
+ assert.match(err.message, /divi\/font/);
190
+ assert.match(err.message, /google_fonts_pattern_a/);
191
+ assert.match(err.message, /UNVERIFIED \(0\)/);
192
+ assert.match(err.message, /absent from the registry entry/);
193
+ return true;
194
+ });
195
+ });
196
+ test("variant isolation: Pattern A evidence does NOT vouch for Pattern B (and vice versa)", () => {
197
+ // Build a synthetic registry containing ONLY the Pattern A divi/font
198
+ // entry (Pattern B variant removed). A Pattern-B caller must be
199
+ // refused — its variant is entirely absent — even though a Pattern A
200
+ // entry under the same `pattern_family` is fully verified.
201
+ const real = loadRegistry();
202
+ const synthetic = JSON.parse(JSON.stringify(real));
203
+ synthetic.tier2 = (synthetic.tier2 ?? []).filter((e) => !(e.pattern_family === "divi/font" &&
204
+ e.pattern_variant === HEADING_FONT_PATTERN_VARIANTS.local));
205
+ assert.throws(() => emitHeadingFontGroupPreset({
206
+ name: "B-without-its-entry",
207
+ pattern: "local",
208
+ family: "Sora 700",
209
+ }, synthetic), (err) => {
210
+ // Variant absent entirely → resolveEvidence throws the
211
+ // "absent from verified-attrs.json" error (not EvidenceGateError).
212
+ assert.match(err.message, /absent from verified-attrs\.json/, "missing variant must NOT silently fall back to the other variant's evidence");
213
+ assert.match(err.message, /local_hosted_pattern_b/);
214
+ return true;
215
+ });
216
+ // The reverse: stripping Pattern A and asking for Pattern A.
217
+ const synthetic2 = JSON.parse(JSON.stringify(real));
218
+ synthetic2.tier2 = (synthetic2.tier2 ?? []).filter((e) => !(e.pattern_family === "divi/font" &&
219
+ e.pattern_variant === HEADING_FONT_PATTERN_VARIANTS.google));
220
+ assert.throws(() => emitHeadingFontGroupPreset({ name: "A-without-its-entry", pattern: "google", family: "Inter" }, synthetic2), /google_fonts_pattern_a/);
221
+ });
222
+ test("buildHeadingFontPresetCreateBody mirrors the diviops_preset_create body shape", () => {
223
+ const entry = emitHeadingFontGroupPreset({ name: "H1", pattern: "google", family: "Inter", weight: "700" }, registry);
224
+ const body = buildHeadingFontPresetCreateBody(entry, { dry_run: true });
225
+ assert.deepEqual(body, {
226
+ module_name: HEADING_FONT_MODULE,
227
+ name: "H1",
228
+ attrs: entry.attrs,
229
+ type: "group",
230
+ group_name: HEADING_FONT_GROUP_NAME,
231
+ group_id: HEADING_FONT_GROUP_ID,
232
+ dry_run: true,
233
+ });
234
+ // `pattern_variant` is in-memory metadata; it must NOT leak into the wire body.
235
+ assert.equal("pattern_variant" in body, false, "pattern_variant is client-side gating metadata; it must not be sent over the wire");
236
+ const noDry = buildHeadingFontPresetCreateBody(entry);
237
+ assert.equal("dry_run" in noDry, false);
238
+ });
239
+ test("real registry: both divi/font variants on divi/heading clear the write threshold", () => {
240
+ // The verified-attrs.json shipped in-repo MUST keep both Pattern A and
241
+ // Pattern B at effective-evidence VB_PRESET_STORAGE_VERIFIED (4) for
242
+ // divi/heading. If a registry edit ever drops one below 4, this test
243
+ // is the canary.
244
+ const reg = loadRegistry();
245
+ const a = emitHeadingFontGroupPreset({ name: "A", pattern: "google", family: "Inter", weight: "700" }, reg);
246
+ assert.equal(a.pattern_variant, HEADING_FONT_PATTERN_VARIANTS.google);
247
+ const b = emitHeadingFontGroupPreset({ name: "B", pattern: "local", family: "Sora 700" }, reg);
248
+ assert.equal(b.pattern_variant, HEADING_FONT_PATTERN_VARIANTS.local);
249
+ });
@@ -132,3 +132,18 @@ test("real registry: divi/button styling families clear the write threshold", ()
132
132
  gateWriteAttr(reg, "divi/button", fam);
133
133
  }
134
134
  });
135
+ test("variant-aware lookup selects the correct entry by pattern_variant", () => {
136
+ // The real registry carries TWO `divi/font` entries that share a
137
+ // pattern_family but differ on pattern_variant. resolveEvidence must
138
+ // honor the variant — a Pattern A query must NOT match the Pattern B
139
+ // entry (and vice versa), even though both share the `divi/font` family.
140
+ const reg = loadRegistry();
141
+ const a = resolveEvidence(reg, "divi/heading", "divi/font", "google_fonts_pattern_a");
142
+ assert.equal(a.patternVariant, "google_fonts_pattern_a");
143
+ assert.equal(a.effectiveLevel, 4, "Pattern A on divi/heading must clear VB_PRESET_STORAGE_VERIFIED in the shipped registry");
144
+ const b = resolveEvidence(reg, "divi/heading", "divi/font", "local_hosted_pattern_b");
145
+ assert.equal(b.patternVariant, "local_hosted_pattern_b");
146
+ assert.equal(b.effectiveLevel, 4, "Pattern B on divi/heading must clear VB_PRESET_STORAGE_VERIFIED in the shipped registry");
147
+ // A bogus variant under a known family throws — no silent fallback.
148
+ assert.throws(() => resolveEvidence(reg, "divi/heading", "divi/font", "no_such_variant"), /absent from verified-attrs\.json/);
149
+ });
@@ -7,8 +7,9 @@
7
7
  */
8
8
  import { test } from "node:test";
9
9
  import assert from "node:assert/strict";
10
- import { applyButtonPreset, assertStorageCapability, buildClientFromEnv, CapabilityMissingError, CredentialsMissingError, STORAGE_CAPABILITY, PRESET_CREATE_ROUTE, } from "../write-path.js";
10
+ import { applyButtonPreset, applyHeadingFontPreset, assertStorageCapability, buildClientFromEnv, CapabilityMissingError, CredentialsMissingError, STORAGE_CAPABILITY, PRESET_CREATE_ROUTE, } from "../write-path.js";
11
11
  import { emitButtonGroupPreset } from "../button-emitter.js";
12
+ import { emitHeadingFontGroupPreset } from "../heading-font-emitter.js";
12
13
  import { loadRegistry } from "../registry.js";
13
14
  const registry = loadRegistry();
14
15
  function handshake(capabilities) {
@@ -101,6 +102,59 @@ test("applyButtonPreset threads dry_run into the body when requested", async ()
101
102
  const options = client.calls[0].options;
102
103
  assert.equal(options.body.dry_run, true);
103
104
  });
105
+ // ------------------------------------------------------------------
106
+ // heading-font apply-mode — mocked only.
107
+ // Mirrors the button apply-mode coverage: capability gate first, then a
108
+ // single POST to /preset/create with the canonical body. pattern_variant
109
+ // is in-memory only and must NOT appear in the wire body.
110
+ // ------------------------------------------------------------------
111
+ test("applyHeadingFontPreset gates capability BEFORE issuing the write", async () => {
112
+ const client = mockClient({ capabilities: {} });
113
+ const entry = emitHeadingFontGroupPreset({ name: "H1", pattern: "google", family: "Inter", weight: "700" }, registry);
114
+ await assert.rejects(() => applyHeadingFontPreset(client, entry, {
115
+ serverVersion: TEST_SERVER_VERSION,
116
+ }), CapabilityMissingError);
117
+ assert.equal(client.calls.length, 0, "no write issued when capability is absent");
118
+ });
119
+ test("applyHeadingFontPreset posts to /preset/create with the canonical body", async () => {
120
+ const client = mockClient({ capabilities: { [STORAGE_CAPABILITY]: true } });
121
+ const entry = emitHeadingFontGroupPreset({
122
+ name: "H1",
123
+ pattern: "google",
124
+ family: "Inter",
125
+ weight: "700",
126
+ color: "gcid-heading-color",
127
+ size: "48px",
128
+ }, registry);
129
+ const result = await applyHeadingFontPreset(client, entry, {
130
+ serverVersion: TEST_SERVER_VERSION,
131
+ });
132
+ assert.equal(client.handshakeVersions[0], TEST_SERVER_VERSION);
133
+ assert.equal(client.calls.length, 1);
134
+ const call = client.calls[0];
135
+ assert.equal(call.endpoint, PRESET_CREATE_ROUTE);
136
+ const options = call.options;
137
+ assert.equal(options.method, "POST");
138
+ assert.equal(options.body.type, "group");
139
+ assert.equal(options.body.module_name, "divi/heading");
140
+ assert.equal(options.body.group_name, "divi/font");
141
+ assert.equal(options.body.group_id, "designTitleText");
142
+ assert.equal(options.body.name, "H1");
143
+ assert.deepEqual(options.body.attrs, entry.attrs);
144
+ assert.equal("pattern_variant" in options.body, false, "pattern_variant is client-side gating metadata; it must not be on the wire");
145
+ assert.equal("dry_run" in options.body, false);
146
+ assert.deepEqual(result, { ok: true, data: { preset_id: "mocked123" } });
147
+ });
148
+ test("applyHeadingFontPreset threads dry_run into the body when requested", async () => {
149
+ const client = mockClient({ capabilities: { [STORAGE_CAPABILITY]: true } });
150
+ const entry = emitHeadingFontGroupPreset({ name: "H1", pattern: "local", family: "Sora 700" }, registry);
151
+ await applyHeadingFontPreset(client, entry, {
152
+ serverVersion: TEST_SERVER_VERSION,
153
+ dry_run: true,
154
+ });
155
+ const options = client.calls[0].options;
156
+ assert.equal(options.body.dry_run, true);
157
+ });
104
158
  test("buildClientFromEnv throws CredentialsMissingError when env vars are absent", () => {
105
159
  assert.throws(() => buildClientFromEnv({}), (err) => {
106
160
  assert.ok(err instanceof CredentialsMissingError);
@@ -23,6 +23,7 @@
23
23
  * 4 write / network error
24
24
  */
25
25
  import { type ButtonEmitterInput } from "./button-emitter.js";
26
+ import { type HeadingFontEmitterInput } from "./heading-font-emitter.js";
26
27
  export declare const EXIT: {
27
28
  readonly OK: 0;
28
29
  readonly INVALID_INPUT: 1;
@@ -50,6 +51,8 @@ export declare class UsageError extends Error {
50
51
  }
51
52
  /** Map parsed `button` options into the emitter input shape. */
52
53
  export declare function buildButtonInput(parsed: ParsedArgs): ButtonEmitterInput;
54
+ /** Map parsed `heading-font` options into the heading-font emitter input shape. */
55
+ export declare function buildHeadingFontInput(parsed: ParsedArgs): HeadingFontEmitterInput;
53
56
  /**
54
57
  * Run the CLI. Returns the structured exit code (does NOT call
55
58
  * `process.exit` — the thin bin wrapper does). `io` is injectable so