@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.
@@ -23,8 +23,9 @@
23
23
  * 4 write / network error
24
24
  */
25
25
  import { emitButtonGroupPreset, buildPresetCreateBody, } from "./button-emitter.js";
26
+ import { emitHeadingFontGroupPreset, buildHeadingFontPresetCreateBody, UnsupportedVariantCombinationError, } from "./heading-font-emitter.js";
26
27
  import { EvidenceGateError } from "./registry.js";
27
- import { applyButtonPreset, buildClientFromEnv, CredentialsMissingError, CapabilityMissingError, } from "./write-path.js";
28
+ import { applyButtonPreset, applyHeadingFontPreset, buildClientFromEnv, CredentialsMissingError, CapabilityMissingError, } from "./write-path.js";
28
29
  export const EXIT = {
29
30
  OK: 0,
30
31
  INVALID_INPUT: 1,
@@ -39,8 +40,10 @@ const realIO = {
39
40
  const HELP = `diviops-preset — Divi 5.5.x canonical preset-emitter CLI
40
41
 
41
42
  USAGE
42
- diviops-preset button [options] Emit a divi/button group preset
43
- diviops-preset --help Show this help
43
+ diviops-preset button [options] Emit a divi/button group preset
44
+ diviops-preset heading-font [options] Emit a divi/font group preset for
45
+ divi/heading
46
+ diviops-preset --help Show this help
44
47
 
45
48
  MODE
46
49
  --dry-run Compose + print canonical JSON only (DEFAULT).
@@ -72,6 +75,30 @@ button OPTIONS (all styling fields optional; emit-on-specification only)
72
75
  .value.padding.top="0px" (hover-padding-gate
73
76
  workaround). Off by default.
74
77
 
78
+ heading-font OPTIONS (all styling fields optional; emit-on-specification only)
79
+ --name <string> Preset display name (required).
80
+ --pattern <google|local> Required. Selects the verified variant:
81
+ google — Pattern A: plain family + explicit
82
+ numeric weight (e.g. family "Inter",
83
+ weight "700"). Verified against
84
+ round-1a fixture.
85
+ local — Pattern B: weight-encoded family
86
+ string (e.g. family "Sora 700") with
87
+ NO --font-weight. Used for local-
88
+ hosted/EU-GDPR font flows. Verified
89
+ against round-1b fixture.
90
+ Pattern A and Pattern B are distinct registry
91
+ variants — there is NO default; omitting
92
+ --pattern is invalid input.
93
+ --font-family <string> Font family. Pattern A: plain name ("Inter").
94
+ Pattern B: weight-encoded ("Sora 700").
95
+ --font-weight <string> Font weight (e.g. "700"). Pattern A only —
96
+ passing it with --pattern local is refused.
97
+ --font-color <value> Font color. Hex literal, bare gcid-*/gvid-*
98
+ token, or already-formed $variable(...)$ token.
99
+ --font-size <v> Font size (e.g. "48px").
100
+ --font-line-height <v> Font line-height (e.g. "1.1").
101
+
75
102
  EXIT CODES
76
103
  0 success 1 invalid input 2 evidence-gate refusal
77
104
  3 capability missing 4 write error
@@ -82,6 +109,14 @@ EXAMPLES
82
109
  --font-family Inter --font-weight 600 --font-color gcid-body-color
83
110
 
84
111
  diviops-preset button --name "Primary" --bg-color "#2563eb" --apply
112
+
113
+ diviops-preset heading-font --name "Heading H1" --pattern google \\
114
+ --font-family Inter --font-weight 700 \\
115
+ --font-color gcid-heading-color --font-size 48px
116
+
117
+ diviops-preset heading-font --name "Heading H1 (local)" --pattern local \\
118
+ --font-family "Sora 700" \\
119
+ --font-color gcid-heading-color --font-size 48px
85
120
  `;
86
121
  const VALUE_FLAGS = new Set([
87
122
  "--name",
@@ -100,6 +135,8 @@ const VALUE_FLAGS = new Set([
100
135
  "--font-weight",
101
136
  "--font-color",
102
137
  "--font-size",
138
+ "--font-line-height",
139
+ "--pattern",
103
140
  ]);
104
141
  /** Parse argv (after `node script`) into a structured shape. Throws on unknown flags. */
105
142
  export function parseArgs(argv) {
@@ -163,6 +200,8 @@ export class UsageError extends Error {
163
200
  this.name = "UsageError";
164
201
  }
165
202
  }
203
+ /** Known CLI commands. The CLI rejects anything else with EXIT.INVALID_INPUT. */
204
+ const KNOWN_COMMANDS = new Set(["button", "heading-font"]);
166
205
  /** Map parsed `button` options into the emitter input shape. */
167
206
  export function buildButtonInput(parsed) {
168
207
  const opt = (k) => {
@@ -244,6 +283,47 @@ export function buildButtonInput(parsed) {
244
283
  }
245
284
  return input;
246
285
  }
286
+ /** Map parsed `heading-font` options into the heading-font emitter input shape. */
287
+ export function buildHeadingFontInput(parsed) {
288
+ const opt = (k) => {
289
+ const v = parsed.options.get(k);
290
+ return typeof v === "string" ? v : undefined;
291
+ };
292
+ const name = opt("--name");
293
+ if (!name) {
294
+ throw new UsageError("heading-font command requires --name <string>.");
295
+ }
296
+ // --pattern is REQUIRED — Pattern A vs Pattern B are distinct registry/
297
+ // evidence variants and there is no safe default. An omitted --pattern
298
+ // is invalid input; the CLI fails BEFORE emission (no guessing).
299
+ const patternRaw = opt("--pattern");
300
+ if (patternRaw === undefined) {
301
+ throw new UsageError("heading-font command requires --pattern <google|local>. " +
302
+ "There is no default — Pattern A (google) and Pattern B (local) are distinct " +
303
+ "registry variants and must be selected intentionally.");
304
+ }
305
+ if (patternRaw !== "google" && patternRaw !== "local") {
306
+ throw new UsageError(`--pattern must be "google" or "local"; got ${JSON.stringify(patternRaw)}.`);
307
+ }
308
+ const pattern = patternRaw;
309
+ const input = { name, pattern };
310
+ const family = opt("--font-family");
311
+ if (family !== undefined)
312
+ input.family = family;
313
+ const weight = opt("--font-weight");
314
+ if (weight !== undefined)
315
+ input.weight = weight;
316
+ const color = opt("--font-color");
317
+ if (color !== undefined)
318
+ input.color = color;
319
+ const size = opt("--font-size");
320
+ if (size !== undefined)
321
+ input.size = size;
322
+ const lineHeight = opt("--font-line-height");
323
+ if (lineHeight !== undefined)
324
+ input.lineHeight = lineHeight;
325
+ return input;
326
+ }
247
327
  /**
248
328
  * Run the CLI. Returns the structured exit code (does NOT call
249
329
  * `process.exit` — the thin bin wrapper does). `io` is injectable so
@@ -264,17 +344,34 @@ export async function run(argv, io = realIO, serverVersion) {
264
344
  io.out(HELP);
265
345
  return EXIT.OK;
266
346
  }
267
- if (parsed.command !== "button") {
347
+ if (parsed.command === null || !KNOWN_COMMANDS.has(parsed.command)) {
268
348
  io.err(`Unknown command: ${parsed.command ?? "(none)"}. ` +
269
- `Track 4 ships one command: "button".`);
349
+ `Known commands: ${[...KNOWN_COMMANDS].sort().join(", ")}.`);
270
350
  io.err("Run `diviops-preset --help` for usage.");
271
351
  return EXIT.INVALID_INPUT;
272
352
  }
273
353
  // --- compose + gate -------------------------------------------------
274
- let entry;
354
+ // Per-command branch produces:
355
+ // - `dryRunBody`: the canonical JSON to print in --dry-run mode.
356
+ // - `applyFn`: a closure that issues the apply-mode write when the
357
+ // capability check passes. Both branches reuse the existing write-
358
+ // path plumbing (`assertStorageCapability` → `/preset/create`).
359
+ let dryRunBody;
360
+ let applyFn;
275
361
  try {
276
- const input = buildButtonInput(parsed);
277
- entry = emitButtonGroupPreset(input);
362
+ if (parsed.command === "button") {
363
+ const input = buildButtonInput(parsed);
364
+ const entry = emitButtonGroupPreset(input);
365
+ dryRunBody = buildPresetCreateBody(entry, { dry_run: true });
366
+ applyFn = (client, sv) => applyButtonPreset(client, entry, { serverVersion: sv });
367
+ }
368
+ else {
369
+ // heading-font
370
+ const input = buildHeadingFontInput(parsed);
371
+ const entry = emitHeadingFontGroupPreset(input);
372
+ dryRunBody = buildHeadingFontPresetCreateBody(entry, { dry_run: true });
373
+ applyFn = (client, sv) => applyHeadingFontPreset(client, entry, { serverVersion: sv });
374
+ }
278
375
  }
279
376
  catch (err) {
280
377
  if (err instanceof EvidenceGateError) {
@@ -286,13 +383,19 @@ export async function run(argv, io = realIO, serverVersion) {
286
383
  io.err("Run `diviops-preset --help` for usage.");
287
384
  return EXIT.INVALID_INPUT;
288
385
  }
386
+ if (err instanceof UnsupportedVariantCombinationError) {
387
+ // Distinct from EvidenceGateError: the registry IS complete for the
388
+ // verified variants, but the caller asked for a combination outside
389
+ // any verified variant. Surfaces as invalid input (exit 1).
390
+ io.err(err.message);
391
+ return EXIT.INVALID_INPUT;
392
+ }
289
393
  io.err(err instanceof Error ? err.message : String(err));
290
394
  return EXIT.INVALID_INPUT;
291
395
  }
292
396
  // --- dry-run --------------------------------------------------------
293
397
  if (parsed.dryRun) {
294
- const body = buildPresetCreateBody(entry, { dry_run: true });
295
- io.out(JSON.stringify(body, null, 2));
398
+ io.out(JSON.stringify(dryRunBody, null, 2));
296
399
  return EXIT.OK;
297
400
  }
298
401
  // --- apply ----------------------------------------------------------
@@ -307,7 +410,7 @@ export async function run(argv, io = realIO, serverVersion) {
307
410
  "This is an internal error — invoke via the `diviops-preset` bin.");
308
411
  return EXIT.WRITE_ERROR;
309
412
  }
310
- const result = await applyButtonPreset(client, entry, { serverVersion });
413
+ const result = await applyFn(client, serverVersion);
311
414
  io.out(JSON.stringify(result, null, 2));
312
415
  return EXIT.OK;
313
416
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `divi/font` heading group-preset emitter — Track 5 vertical slice.
3
+ *
4
+ * Emits a byte-canonical Divi 5.5.x `type: "group"` `divi/font` preset
5
+ * targeting `divi/heading` at `attrs.title.decoration.font.font.desktop.value.*`,
6
+ * gated by the verified-attrs registry. Canonical shape:
7
+ * `docs/verification/canonical-preset-shapes-per-module-type-2026-05-18.md`
8
+ * (`divi/font` section), cross-checked against
9
+ * `docs/verification/evidence/canonical-shape-dumps-2026-05-18/round-1a-heading-h1-pattern-a-google.json`
10
+ * and `…/round-1b-heading-h1-pattern-b-local.json`.
11
+ *
12
+ * Two verified Pattern variants live in the registry — they are NOT
13
+ * interchangeable evidence. The caller MUST select one explicitly:
14
+ *
15
+ * - Pattern A (Google Fonts CDN): plain family + explicit numeric weight.
16
+ * Registry entry: `divi/font` with `pattern_variant: "google_fonts_pattern_a"`.
17
+ * Verified evidence: round-1a fixture.
18
+ * - Pattern B (local-hosted, EU-GDPR): weight encoded into the family
19
+ * string ("Sora 700") and NO `weight` key at all in the preset.
20
+ * Registry entry: `divi/font` with `pattern_variant: "local_hosted_pattern_b"`.
21
+ * Verified evidence: round-1b fixture.
22
+ *
23
+ * Shape rules enforced here:
24
+ * - Emit-on-specification only — omitted params produce NO keys.
25
+ * - `attrs == styleAttrs == renderAttrs` at the route layer (the plugin's
26
+ * `/preset/create` route mirrors `attrs` into all three buckets to match
27
+ * VB save semantics); the CLI request body only carries `attrs`.
28
+ * - Double-font nesting: `title.decoration.font.font.desktop.value.*` —
29
+ * outer `font` is the decoration category, inner `font` is the attr
30
+ * name within that category.
31
+ * - Pattern B with an explicit `weight` is REFUSED — the verified Pattern
32
+ * B capture has no `weight` key and there is no registry entry
33
+ * authorizing local-hosted-with-explicit-weight.
34
+ */
35
+ import { type VerifiedAttrsRegistry } from "./registry.js";
36
+ export declare const HEADING_FONT_MODULE = "divi/heading";
37
+ export declare const HEADING_FONT_GROUP_NAME = "divi/font";
38
+ export declare const HEADING_FONT_GROUP_ID = "designTitleText";
39
+ export declare const HEADING_FONT_PATTERN_FAMILY = "divi/font";
40
+ /** The two verified pattern variants for `divi/font` on `divi/heading`. */
41
+ export declare const HEADING_FONT_PATTERN_VARIANTS: {
42
+ readonly google: "google_fonts_pattern_a";
43
+ readonly local: "local_hosted_pattern_b";
44
+ };
45
+ export type HeadingFontPattern = "google" | "local";
46
+ export interface HeadingFontEmitterInput {
47
+ /** Required display name for the preset. */
48
+ name: string;
49
+ /**
50
+ * Required pattern selector. There is no default — Pattern A and
51
+ * Pattern B are distinct registry/evidence variants and the caller
52
+ * must select intentionally.
53
+ */
54
+ pattern: HeadingFontPattern;
55
+ /**
56
+ * Font family.
57
+ * - Pattern A (google): plain family name, e.g. `"Inter"`.
58
+ * - Pattern B (local): weight-encoded family name, e.g. `"Sora 700"`.
59
+ */
60
+ family?: string;
61
+ /**
62
+ * Font weight (numeric-string, e.g. `"700"`).
63
+ * - Pattern A: optional; emitted when specified.
64
+ * - Pattern B: PROHIBITED — passing a weight in `local` mode is a
65
+ * usage error (the verified Pattern B shape has no `weight` key,
66
+ * and there is no registry entry vouching for that combination).
67
+ */
68
+ weight?: string;
69
+ /** Font color — literal hex or bare/formed variable token. */
70
+ color?: string;
71
+ /** Font size — literal (e.g. `"48px"`) or already-formed `$variable(...)$`. */
72
+ size?: string;
73
+ /** Line height — optional, emit-on-specification only. */
74
+ lineHeight?: string;
75
+ }
76
+ /** The composed canonical preset entry shape sent to `/preset/create`. */
77
+ export interface HeadingFontPresetEntry {
78
+ type: "group";
79
+ module_name: string;
80
+ group_name: string;
81
+ group_id: string;
82
+ pattern_variant: string;
83
+ name: string;
84
+ attrs: Record<string, unknown>;
85
+ }
86
+ /**
87
+ * Raised when the caller passes a combination the registry does not
88
+ * authorize for the current Track — distinct from `EvidenceGateError`
89
+ * (registry-evidence shortfall) and `UsageError` (CLI arg parse).
90
+ */
91
+ export declare class UnsupportedVariantCombinationError extends Error {
92
+ constructor(message: string);
93
+ }
94
+ /**
95
+ * Compose the canonical `attrs.title.decoration.font.font.desktop.value`
96
+ * bag from the input. Emit-on-specification: only specified sub-fields
97
+ * produce keys.
98
+ */
99
+ export declare function composeHeadingFontAttrs(input: HeadingFontEmitterInput): Record<string, unknown>;
100
+ /**
101
+ * Emit a canonical `divi/font` heading group preset.
102
+ *
103
+ * 1. Validate input shape (name, pattern, Pattern B + weight refusal).
104
+ * 2. Compose `attrs.title.decoration.font.font.desktop.value.*`
105
+ * (emit-on-specification).
106
+ * 3. Gate the chosen `(divi/font, pattern_variant)` cell on `divi/heading`
107
+ * against the verified-attrs registry — throws `EvidenceGateError`
108
+ * when effective evidence is below `VB_PRESET_STORAGE_VERIFIED`.
109
+ *
110
+ * `styleAttrs` and `renderAttrs` are intentionally NOT part of this
111
+ * entry: the plugin's `/preset/create` route mirrors the single `attrs`
112
+ * bag into all three buckets, writing `attrs == styleAttrs == renderAttrs`
113
+ * to match VB save semantics — see `trait-preset.php` `preset_create`.
114
+ * The Round 1a/1b fixtures capture the post-write storage shape.
115
+ */
116
+ export declare function emitHeadingFontGroupPreset(input: HeadingFontEmitterInput, registry?: VerifiedAttrsRegistry): HeadingFontPresetEntry;
117
+ /**
118
+ * Build the `POST /diviops/v1/preset/create` request body from a heading
119
+ * font preset entry. Matches the body shape the `diviops_preset_create`
120
+ * MCP tool posts — the CLI reuses the existing route, it does not add one.
121
+ *
122
+ * `pattern_variant` is informational metadata on the in-memory entry and
123
+ * is NOT sent over the wire — the registry-gate decision happens
124
+ * client-side before the write.
125
+ */
126
+ export declare function buildHeadingFontPresetCreateBody(entry: HeadingFontPresetEntry, opts?: {
127
+ dry_run?: boolean;
128
+ }): Record<string, unknown>;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `divi/font` heading group-preset emitter — Track 5 vertical slice.
3
+ *
4
+ * Emits a byte-canonical Divi 5.5.x `type: "group"` `divi/font` preset
5
+ * targeting `divi/heading` at `attrs.title.decoration.font.font.desktop.value.*`,
6
+ * gated by the verified-attrs registry. Canonical shape:
7
+ * `docs/verification/canonical-preset-shapes-per-module-type-2026-05-18.md`
8
+ * (`divi/font` section), cross-checked against
9
+ * `docs/verification/evidence/canonical-shape-dumps-2026-05-18/round-1a-heading-h1-pattern-a-google.json`
10
+ * and `…/round-1b-heading-h1-pattern-b-local.json`.
11
+ *
12
+ * Two verified Pattern variants live in the registry — they are NOT
13
+ * interchangeable evidence. The caller MUST select one explicitly:
14
+ *
15
+ * - Pattern A (Google Fonts CDN): plain family + explicit numeric weight.
16
+ * Registry entry: `divi/font` with `pattern_variant: "google_fonts_pattern_a"`.
17
+ * Verified evidence: round-1a fixture.
18
+ * - Pattern B (local-hosted, EU-GDPR): weight encoded into the family
19
+ * string ("Sora 700") and NO `weight` key at all in the preset.
20
+ * Registry entry: `divi/font` with `pattern_variant: "local_hosted_pattern_b"`.
21
+ * Verified evidence: round-1b fixture.
22
+ *
23
+ * Shape rules enforced here:
24
+ * - Emit-on-specification only — omitted params produce NO keys.
25
+ * - `attrs == styleAttrs == renderAttrs` at the route layer (the plugin's
26
+ * `/preset/create` route mirrors `attrs` into all three buckets to match
27
+ * VB save semantics); the CLI request body only carries `attrs`.
28
+ * - Double-font nesting: `title.decoration.font.font.desktop.value.*` —
29
+ * outer `font` is the decoration category, inner `font` is the attr
30
+ * name within that category.
31
+ * - Pattern B with an explicit `weight` is REFUSED — the verified Pattern
32
+ * B capture has no `weight` key and there is no registry entry
33
+ * authorizing local-hosted-with-explicit-weight.
34
+ */
35
+ import { loadRegistry, gateWriteAttr, } from "./registry.js";
36
+ import { normalizeColorValue } from "./variable-token.js";
37
+ export const HEADING_FONT_MODULE = "divi/heading";
38
+ export const HEADING_FONT_GROUP_NAME = "divi/font";
39
+ export const HEADING_FONT_GROUP_ID = "designTitleText";
40
+ export const HEADING_FONT_PATTERN_FAMILY = "divi/font";
41
+ /** The two verified pattern variants for `divi/font` on `divi/heading`. */
42
+ export const HEADING_FONT_PATTERN_VARIANTS = {
43
+ google: "google_fonts_pattern_a",
44
+ local: "local_hosted_pattern_b",
45
+ };
46
+ /**
47
+ * Raised when the caller passes a combination the registry does not
48
+ * authorize for the current Track — distinct from `EvidenceGateError`
49
+ * (registry-evidence shortfall) and `UsageError` (CLI arg parse).
50
+ */
51
+ export class UnsupportedVariantCombinationError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = "UnsupportedVariantCombinationError";
55
+ }
56
+ }
57
+ /**
58
+ * Compose the canonical `attrs.title.decoration.font.font.desktop.value`
59
+ * bag from the input. Emit-on-specification: only specified sub-fields
60
+ * produce keys.
61
+ */
62
+ export function composeHeadingFontAttrs(input) {
63
+ const value = {};
64
+ if (input.family !== undefined)
65
+ value.family = input.family;
66
+ if (input.weight !== undefined)
67
+ value.weight = input.weight;
68
+ if (input.color !== undefined)
69
+ value.color = normalizeColorValue(input.color);
70
+ if (input.size !== undefined)
71
+ value.size = input.size;
72
+ if (input.lineHeight !== undefined)
73
+ value.lineHeight = input.lineHeight;
74
+ return {
75
+ title: {
76
+ decoration: {
77
+ font: {
78
+ font: {
79
+ desktop: {
80
+ value,
81
+ },
82
+ },
83
+ },
84
+ },
85
+ },
86
+ };
87
+ }
88
+ /**
89
+ * Emit a canonical `divi/font` heading group preset.
90
+ *
91
+ * 1. Validate input shape (name, pattern, Pattern B + weight refusal).
92
+ * 2. Compose `attrs.title.decoration.font.font.desktop.value.*`
93
+ * (emit-on-specification).
94
+ * 3. Gate the chosen `(divi/font, pattern_variant)` cell on `divi/heading`
95
+ * against the verified-attrs registry — throws `EvidenceGateError`
96
+ * when effective evidence is below `VB_PRESET_STORAGE_VERIFIED`.
97
+ *
98
+ * `styleAttrs` and `renderAttrs` are intentionally NOT part of this
99
+ * entry: the plugin's `/preset/create` route mirrors the single `attrs`
100
+ * bag into all three buckets, writing `attrs == styleAttrs == renderAttrs`
101
+ * to match VB save semantics — see `trait-preset.php` `preset_create`.
102
+ * The Round 1a/1b fixtures capture the post-write storage shape.
103
+ */
104
+ export function emitHeadingFontGroupPreset(input, registry = loadRegistry()) {
105
+ if (!input.name || typeof input.name !== "string") {
106
+ throw new Error("Heading-font emitter requires a non-empty `name`.");
107
+ }
108
+ if (input.pattern !== "google" && input.pattern !== "local") {
109
+ throw new Error(`Heading-font emitter requires \`pattern\` to be "google" or "local"; got ${JSON.stringify(input.pattern)}. ` +
110
+ `There is no default — Pattern A (google) and Pattern B (local) are distinct registry/evidence variants.`);
111
+ }
112
+ // Pattern B + explicit weight is out-of-scope for Track 5. The verified
113
+ // Pattern B capture proves `weight` is absent; no registry entry
114
+ // currently authorizes a local-hosted-with-explicit-weight shape.
115
+ if (input.pattern === "local" && input.weight !== undefined) {
116
+ throw new UnsupportedVariantCombinationError(`Pattern B (local-hosted) does not support an explicit \`weight\` — the verified ` +
117
+ `Pattern B shape has no \`weight\` key, and there is no registry variant ` +
118
+ `authorizing local-hosted-with-explicit-weight. Encode the weight into the ` +
119
+ `family string instead (e.g. \`family: "Sora 700"\`). A future registry entry ` +
120
+ `can authorize that shape if VB evidence supports it.`);
121
+ }
122
+ const attrs = composeHeadingFontAttrs(input);
123
+ // Sanity check: at least one styling field was specified. An empty
124
+ // value bag is a usage error — there is nothing to write.
125
+ const value = attrs.title.decoration.font.font.desktop.value;
126
+ if (!value || Object.keys(value).length === 0) {
127
+ throw new Error("Heading-font emitter produced an empty preset — pass at least one of " +
128
+ "family, weight, color, size, or lineHeight.");
129
+ }
130
+ // Registry gate: variant-aware. Pattern A evidence must NOT vouch for
131
+ // Pattern B and vice versa — gateWriteAttr resolves by both family AND
132
+ // variant, so a missing/under-verified variant entry throws here.
133
+ const patternVariant = HEADING_FONT_PATTERN_VARIANTS[input.pattern];
134
+ gateWriteAttr(registry, HEADING_FONT_MODULE, HEADING_FONT_PATTERN_FAMILY, patternVariant);
135
+ return {
136
+ type: "group",
137
+ module_name: HEADING_FONT_MODULE,
138
+ group_name: HEADING_FONT_GROUP_NAME,
139
+ group_id: HEADING_FONT_GROUP_ID,
140
+ pattern_variant: patternVariant,
141
+ name: input.name,
142
+ attrs,
143
+ };
144
+ }
145
+ /**
146
+ * Build the `POST /diviops/v1/preset/create` request body from a heading
147
+ * font preset entry. Matches the body shape the `diviops_preset_create`
148
+ * MCP tool posts — the CLI reuses the existing route, it does not add one.
149
+ *
150
+ * `pattern_variant` is informational metadata on the in-memory entry and
151
+ * is NOT sent over the wire — the registry-gate decision happens
152
+ * client-side before the write.
153
+ */
154
+ export function buildHeadingFontPresetCreateBody(entry, opts = {}) {
155
+ const body = {
156
+ module_name: entry.module_name,
157
+ name: entry.name,
158
+ attrs: entry.attrs,
159
+ type: entry.type,
160
+ group_name: entry.group_name,
161
+ group_id: entry.group_id,
162
+ };
163
+ if (opts.dry_run)
164
+ body.dry_run = true;
165
+ return body;
166
+ }
@@ -48,9 +48,11 @@ export interface VerifiedAttrsRegistry {
48
48
  tier2?: Tier12Entry[];
49
49
  tier3?: unknown[];
50
50
  }
51
- /** Resolution of a single `(module, pattern-family)` cell against the registry. */
51
+ /** Resolution of a single `(module, pattern-family[, pattern-variant])` cell against the registry. */
52
52
  export interface EvidenceResolution {
53
53
  patternFamily: string;
54
+ /** The matched entry's `pattern_variant`, if any. */
55
+ patternVariant?: string;
54
56
  module: string;
55
57
  /** Numeric pattern-level evidence (0–5). */
56
58
  patternLevel: number;
@@ -78,17 +80,29 @@ export declare function loadRegistry(explicitPath?: string): VerifiedAttrsRegist
78
80
  /** Reset the module-level cache. Used by tests. */
79
81
  export declare function resetRegistryCache(): void;
80
82
  /**
81
- * Find a Tier 1 / Tier 2 entry by `pattern_family`. Tier 2 is searched
82
- * first (the button families live there), then Tier 1.
83
+ * Find a Tier 1 / Tier 2 entry by `pattern_family` and (optionally)
84
+ * `pattern_variant`. Tier 2 is searched first, then Tier 1.
85
+ *
86
+ * Some pattern families (e.g. `divi/font`) carry multiple entries that
87
+ * differ ONLY by `pattern_variant` (`google_fonts_pattern_a` vs
88
+ * `local_hosted_pattern_b`). When `patternVariant` is supplied, ONLY an
89
+ * entry whose `pattern_variant` matches exactly is returned — a Pattern-A
90
+ * entry must not vouch for a Pattern-B caller, and vice versa.
91
+ *
92
+ * When `patternVariant` is omitted, the legacy behavior holds: the first
93
+ * matching `pattern_family` is returned regardless of variant — only safe
94
+ * for families with a single entry. New variant-aware callers should pass
95
+ * the variant explicitly.
83
96
  */
84
- export declare function findPatternEntry(registry: VerifiedAttrsRegistry, patternFamily: string): Tier12Entry | undefined;
97
+ export declare function findPatternEntry(registry: VerifiedAttrsRegistry, patternFamily: string, patternVariant?: string): Tier12Entry | undefined;
85
98
  /**
86
- * Resolve effective evidence for a `(module, pattern-family)` cell.
99
+ * Resolve effective evidence for a `(module, pattern-family[, pattern-variant])` cell.
87
100
  *
88
- * Throws if the pattern family is entirely absent from the registry
89
- * that is an unrecoverable gap (the CLI must fail, not guess).
101
+ * Throws if the pattern family (or, when supplied, the specific variant)
102
+ * is entirely absent from the registry that is an unrecoverable gap
103
+ * (the CLI must fail, not guess).
90
104
  */
91
- export declare function resolveEvidence(registry: VerifiedAttrsRegistry, module: string, patternFamily: string): EvidenceResolution;
105
+ export declare function resolveEvidence(registry: VerifiedAttrsRegistry, module: string, patternFamily: string, patternVariant?: string): EvidenceResolution;
92
106
  /** Numeric ordering of the write-emitter threshold against a registry. */
93
107
  export declare function writeThresholdNumber(registry: VerifiedAttrsRegistry): number;
94
108
  /**
@@ -104,4 +118,4 @@ export declare class EvidenceGateError extends Error {
104
118
  readonly thresholdName: string;
105
119
  constructor(patternFamily: string, module: string, resolution: EvidenceResolution, thresholdNumber: number, thresholdName: string);
106
120
  }
107
- export declare function gateWriteAttr(registry: VerifiedAttrsRegistry, module: string, patternFamily: string): EvidenceResolution;
121
+ export declare function gateWriteAttr(registry: VerifiedAttrsRegistry, module: string, patternFamily: string, patternVariant?: string): EvidenceResolution;
@@ -77,28 +77,48 @@ function levelName(registry, num) {
77
77
  return "UNVERIFIED";
78
78
  }
79
79
  /**
80
- * Find a Tier 1 / Tier 2 entry by `pattern_family`. Tier 2 is searched
81
- * first (the button families live there), then Tier 1.
80
+ * Find a Tier 1 / Tier 2 entry by `pattern_family` and (optionally)
81
+ * `pattern_variant`. Tier 2 is searched first, then Tier 1.
82
+ *
83
+ * Some pattern families (e.g. `divi/font`) carry multiple entries that
84
+ * differ ONLY by `pattern_variant` (`google_fonts_pattern_a` vs
85
+ * `local_hosted_pattern_b`). When `patternVariant` is supplied, ONLY an
86
+ * entry whose `pattern_variant` matches exactly is returned — a Pattern-A
87
+ * entry must not vouch for a Pattern-B caller, and vice versa.
88
+ *
89
+ * When `patternVariant` is omitted, the legacy behavior holds: the first
90
+ * matching `pattern_family` is returned regardless of variant — only safe
91
+ * for families with a single entry. New variant-aware callers should pass
92
+ * the variant explicitly.
82
93
  */
83
- export function findPatternEntry(registry, patternFamily) {
94
+ export function findPatternEntry(registry, patternFamily, patternVariant) {
84
95
  const tiers = [registry.tier2 ?? [], registry.tier1 ?? []];
85
96
  for (const tier of tiers) {
86
- const hit = tier.find((e) => e.pattern_family === patternFamily);
97
+ const hit = tier.find((e) => {
98
+ if (e.pattern_family !== patternFamily)
99
+ return false;
100
+ if (patternVariant !== undefined) {
101
+ return e.pattern_variant === patternVariant;
102
+ }
103
+ return true;
104
+ });
87
105
  if (hit)
88
106
  return hit;
89
107
  }
90
108
  return undefined;
91
109
  }
92
110
  /**
93
- * Resolve effective evidence for a `(module, pattern-family)` cell.
111
+ * Resolve effective evidence for a `(module, pattern-family[, pattern-variant])` cell.
94
112
  *
95
- * Throws if the pattern family is entirely absent from the registry
96
- * that is an unrecoverable gap (the CLI must fail, not guess).
113
+ * Throws if the pattern family (or, when supplied, the specific variant)
114
+ * is entirely absent from the registry that is an unrecoverable gap
115
+ * (the CLI must fail, not guess).
97
116
  */
98
- export function resolveEvidence(registry, module, patternFamily) {
99
- const entry = findPatternEntry(registry, patternFamily);
117
+ export function resolveEvidence(registry, module, patternFamily, patternVariant) {
118
+ const entry = findPatternEntry(registry, patternFamily, patternVariant);
100
119
  if (!entry) {
101
- throw new Error(`Pattern family "${patternFamily}" is absent from verified-attrs.json. ` +
120
+ const variantSuffix = patternVariant !== undefined ? ` (variant "${patternVariant}")` : "";
121
+ throw new Error(`Pattern family "${patternFamily}"${variantSuffix} is absent from verified-attrs.json. ` +
102
122
  `The CLI cannot emit an unregistered attr. File this as a registry-gap backlog candidate.`);
103
123
  }
104
124
  const patternLevel = levelNumber(registry, entry.pattern_evidence_level);
@@ -113,6 +133,7 @@ export function resolveEvidence(registry, module, patternFamily) {
113
133
  "verified-attrs.json (no source field)";
114
134
  return {
115
135
  patternFamily,
136
+ patternVariant: entry.pattern_variant,
116
137
  module,
117
138
  patternLevel,
118
139
  patternLevelName: entry.pattern_evidence_level,
@@ -141,7 +162,10 @@ export class EvidenceGateError extends Error {
141
162
  thresholdNumber;
142
163
  thresholdName;
143
164
  constructor(patternFamily, module, resolution, thresholdNumber, thresholdName) {
144
- super(`Evidence-gate refusal: attr family "${patternFamily}" on module "${module}" ` +
165
+ const variantTag = resolution.patternVariant !== undefined
166
+ ? ` variant "${resolution.patternVariant}"`
167
+ : "";
168
+ super(`Evidence-gate refusal: attr family "${patternFamily}"${variantTag} on module "${module}" ` +
145
169
  `has EFFECTIVE evidence ${resolution.effectiveLevelName} (${resolution.effectiveLevel}), ` +
146
170
  `below the write threshold ${thresholdName} (${thresholdNumber}). ` +
147
171
  (resolution.applicabilityMissing
@@ -158,8 +182,8 @@ export class EvidenceGateError extends Error {
158
182
  this.name = "EvidenceGateError";
159
183
  }
160
184
  }
161
- export function gateWriteAttr(registry, module, patternFamily) {
162
- const resolution = resolveEvidence(registry, module, patternFamily);
185
+ export function gateWriteAttr(registry, module, patternFamily, patternVariant) {
186
+ const resolution = resolveEvidence(registry, module, patternFamily, patternVariant);
163
187
  const thresholdNumber = writeThresholdNumber(registry);
164
188
  if (resolution.effectiveLevel < thresholdNumber) {
165
189
  throw new EvidenceGateError(patternFamily, module, resolution, thresholdNumber, WRITE_EMITTER_THRESHOLD_LEVEL);