@nextcommerce/campaigns-os 1.43.2 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +648 -5103
- package/README.md +32 -11
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1176 -113
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2345 -6087
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +158 -21
- package/docs/campaigns-os-build-flow.md +3 -3
- package/docs/design-source-package.md +73 -0
- package/docs/effects.md +50 -8
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/qa-and-test-orders.md +99 -13
- package/docs/release-ledger-authoring-guide.md +64 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/built-site-scope.mjs +16 -4
- package/src/cli.mjs +280 -46
- package/src/commercial-parity.mjs +48 -2
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +5 -2
- package/src/doctor/checks.mjs +320 -81
- package/src/doctor/inspect.mjs +55 -13
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/invocation.mjs +4 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/progress-node.mjs +3 -1
- package/src/qa-browser.mjs +538 -28
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +122 -7
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +116 -0
- package/src/stage-record.mjs +551 -0
- package/src/upsell-selector-scope.mjs +112 -2
package/src/brand-theme.mjs
CHANGED
|
@@ -169,16 +169,21 @@ function extractStyleBlocks(content) {
|
|
|
169
169
|
return blocks;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// A commented-out declaration is not part of the design (#535): every token
|
|
173
|
+
// and rule extraction below reads the CSS with comments removed first.
|
|
174
|
+
function stripCssComments(content) {
|
|
175
|
+
return String(content || "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
176
|
+
}
|
|
177
|
+
|
|
172
178
|
export function parseRootCustomProperties(content) {
|
|
173
179
|
const tokens = {};
|
|
174
180
|
const warnings = [];
|
|
175
|
-
for (const block of extractRootBlocks(content)) {
|
|
181
|
+
for (const block of extractRootBlocks(stripCssComments(content))) {
|
|
176
182
|
const declarationPattern = /(--[A-Za-z0-9_-]+)\s*:\s*([^;{}]+);/g;
|
|
177
183
|
for (const match of block.body.matchAll(declarationPattern)) {
|
|
178
184
|
tokens[match[1].trim()] = match[2].trim();
|
|
179
185
|
}
|
|
180
186
|
const bodyWithoutMatches = block.body
|
|
181
|
-
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
182
187
|
.replace(declarationPattern, "")
|
|
183
188
|
.trim();
|
|
184
189
|
if (bodyWithoutMatches) {
|
|
@@ -207,10 +212,17 @@ function isSurfaceBgToken(parts) {
|
|
|
207
212
|
|| hasTokenSequence(parts, ["bg"], ["page", "body", "site"]);
|
|
208
213
|
}
|
|
209
214
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
215
|
+
// Inverse / on-colour label tokens, whatever the word order (#535). The name
|
|
216
|
+
// needs a text or foreground part, plus either "inverse" or "on" followed by a
|
|
217
|
+
// coloured or dark background word: --text-inverse, --inverse-text,
|
|
218
|
+
// --text-color-inverse, --on-primary-text, --text-on-dark, --foreground-on-cta.
|
|
219
|
+
// --border-on-primary and --overlay-on-dark are not text; --text-on-light is
|
|
220
|
+
// ordinary dark copy. The one rule for inverse text: token inference, the
|
|
221
|
+
// declared CTA label and body text all read it.
|
|
222
|
+
function isTextLabelToken(parts) {
|
|
223
|
+
if (!hasTokenPart(parts, ["text", "foreground"])) return false;
|
|
224
|
+
return hasTokenPart(parts, ["inverse"])
|
|
225
|
+
|| hasTokenSequence(parts, ["on"], ["primary", "cta", "brand", "accent", "dark"]);
|
|
214
226
|
}
|
|
215
227
|
|
|
216
228
|
function isTargetContractToken(name) {
|
|
@@ -251,13 +263,13 @@ function inferDesignIntentTokens(content, rootTokens = {}) {
|
|
|
251
263
|
}
|
|
252
264
|
if (hasTokenPart(parts, ["text"]) && hasTokenPart(parts, ["primary", "main"])) addToken("--text-primary", color);
|
|
253
265
|
if (hasTokenPart(parts, ["text", "foreground"]) && hasTokenPart(parts, ["secondary", "muted", "subtle"])) addToken("--text-secondary", color);
|
|
254
|
-
if (
|
|
266
|
+
if (isTextLabelToken(parts)) addToken("--text-inverse", color);
|
|
255
267
|
if (hasTokenPart(parts, ["border", "outline", "stroke", "ring"])) addToken("--border-default", color);
|
|
256
268
|
if (hasTokenPart(parts, ["rating", "star", "review"])) addToken("--rating-star", color);
|
|
257
269
|
}
|
|
258
270
|
|
|
259
271
|
const rulePattern = /([^{}]+)\{([^{}]+)\}/g;
|
|
260
|
-
for (const match of content.matchAll(rulePattern)) {
|
|
272
|
+
for (const match of stripCssComments(content).matchAll(rulePattern)) {
|
|
261
273
|
const selector = match[1] || "";
|
|
262
274
|
for (const declaration of match[2].split(";")) {
|
|
263
275
|
const [rawName, ...rawValueParts] = declaration.split(":");
|
|
@@ -298,6 +310,168 @@ function isCtaLikeSelector(selector) {
|
|
|
298
310
|
return /(?:^|[.#\s:_-])(?:cta|button|btn|submit|cart|buy|order)(?:$|[.#\s:_-])/i.test(String(selector || ""));
|
|
299
311
|
}
|
|
300
312
|
|
|
313
|
+
// Functional pseudo-classes whose arguments are other selectors. They are
|
|
314
|
+
// read past whole: `button:not(.order-summary)` is still the button, and
|
|
315
|
+
// `.cart:has(.button)` is still the cart.
|
|
316
|
+
const SELECTOR_ARGUMENT_PSEUDOS = new Set(["not", "is", "where", "has"]);
|
|
317
|
+
|
|
318
|
+
// Tokenises a selector list into the rightmost compound selector of each
|
|
319
|
+
// selector, as { type, classes, attributes, pseudos } (#535). Strings,
|
|
320
|
+
// escapes, [attribute] selectors and pseudo-class arguments are consumed
|
|
321
|
+
// whole, so class-like text inside them (`[data-target=".btn"]`, `:not(.btn)`)
|
|
322
|
+
// is never read as the compound's own class. null when the list is empty or
|
|
323
|
+
// has anything this reader does not understand.
|
|
324
|
+
function rightmostCompounds(selectorList) {
|
|
325
|
+
const text = String(selectorList || "");
|
|
326
|
+
const compounds = [];
|
|
327
|
+
const fresh = () => ({ type: null, classes: [], attributes: [], pseudos: [] });
|
|
328
|
+
const isEmpty = (compound) => !compound.type && !compound.classes.length && !compound.attributes.length && !compound.pseudos.length;
|
|
329
|
+
let current = fresh();
|
|
330
|
+
let afterCombinator = false;
|
|
331
|
+
let i = 0;
|
|
332
|
+
const readName = () => {
|
|
333
|
+
const start = i;
|
|
334
|
+
while (i < text.length && (/[A-Za-z0-9_\- -]/.test(text[i]) || text[i] === "\\")) i += text[i] === "\\" ? 2 : 1;
|
|
335
|
+
return text.slice(start, i);
|
|
336
|
+
};
|
|
337
|
+
// Consumes a balanced [...] or (...) group from text[i], strings included.
|
|
338
|
+
const readGroup = (open, close) => {
|
|
339
|
+
const start = i;
|
|
340
|
+
let depth = 0;
|
|
341
|
+
while (i < text.length) {
|
|
342
|
+
const ch = text[i];
|
|
343
|
+
if (ch === "\\") { i += 2; continue; }
|
|
344
|
+
if (ch === "\"" || ch === "'") {
|
|
345
|
+
i += 1;
|
|
346
|
+
while (i < text.length && text[i] !== ch) i += text[i] === "\\" ? 2 : 1;
|
|
347
|
+
i += 1;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (ch === open) depth += 1;
|
|
351
|
+
if (ch === close && --depth === 0) return text.slice(start + 1, i++);
|
|
352
|
+
i += 1;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
};
|
|
356
|
+
while (i < text.length) {
|
|
357
|
+
const ch = text[i];
|
|
358
|
+
if (/[\s>+~]/.test(ch)) { afterCombinator = true; i += 1; continue; }
|
|
359
|
+
if (ch === ",") {
|
|
360
|
+
if (isEmpty(current)) return null;
|
|
361
|
+
compounds.push(current);
|
|
362
|
+
current = fresh();
|
|
363
|
+
afterCombinator = false;
|
|
364
|
+
i += 1;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (afterCombinator) { current = fresh(); afterCombinator = false; }
|
|
368
|
+
if (ch === "." || ch === "#") {
|
|
369
|
+
i += 1;
|
|
370
|
+
const name = readName();
|
|
371
|
+
if (!name) return null;
|
|
372
|
+
if (ch === ".") current.classes.push(name);
|
|
373
|
+
} else if (ch === "[") {
|
|
374
|
+
const attribute = readGroup("[", "]");
|
|
375
|
+
if (attribute === null) return null;
|
|
376
|
+
current.attributes.push(attribute);
|
|
377
|
+
} else if (ch === ":") {
|
|
378
|
+
i += text[i + 1] === ":" ? 2 : 1;
|
|
379
|
+
const name = readName().toLowerCase();
|
|
380
|
+
if (!name) return null;
|
|
381
|
+
const hasArguments = text[i] === "(";
|
|
382
|
+
if (hasArguments && readGroup("(", ")") === null) return null;
|
|
383
|
+
if (!(hasArguments && SELECTOR_ARGUMENT_PSEUDOS.has(name))) current.pseudos.push(name);
|
|
384
|
+
} else if (ch === "*") {
|
|
385
|
+
i += 1;
|
|
386
|
+
current.type = "*";
|
|
387
|
+
} else {
|
|
388
|
+
const name = readName();
|
|
389
|
+
if (!name) return null;
|
|
390
|
+
current.type = name.toLowerCase();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (isEmpty(current)) return null;
|
|
394
|
+
compounds.push(current);
|
|
395
|
+
return compounds;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// A selector that is unambiguously a button or CTA, for reading the CTA label
|
|
399
|
+
// colour (#535). isCtaLikeSelector is too broad for that (.order-summary,
|
|
400
|
+
// .cart-count). Every selector in the list must qualify, judged on its
|
|
401
|
+
// rightmost compound selector's own parts (rightmostCompounds): it carries no
|
|
402
|
+
// state pseudo-class or pseudo-element (:hover, :disabled, ::before) and is
|
|
403
|
+
// one of: the `button` element; `input[type=submit]`; or a class that
|
|
404
|
+
// starts with `btn`, `button` or `cta`, or has a `cta` part (.cta, .hero-cta,
|
|
405
|
+
// .cta-primary, .ctaButton). An attribute alone never qualifies:
|
|
406
|
+
// `[type=submit]`, `div[type=submit]` and `.order-summary[type=submit]` are
|
|
407
|
+
// not buttons.
|
|
408
|
+
function isButtonSelector(selector) {
|
|
409
|
+
const compounds = rightmostCompounds(selector);
|
|
410
|
+
if (!compounds) return false;
|
|
411
|
+
return compounds.every((compound) => {
|
|
412
|
+
if (compound.pseudos.length) return false;
|
|
413
|
+
if (compound.type === "button") return true;
|
|
414
|
+
if (compound.type === "input" && compound.attributes.some((attribute) => /^\s*type\s*=\s*(["']?)submit\1\s*(?:i\s*)?$/i.test(attribute))) return true;
|
|
415
|
+
return compound.classes.some((name) => (
|
|
416
|
+
/^(?:btn|button|cta)/i.test(name) || name.toLowerCase().split(/[-_]+/).includes("cta")
|
|
417
|
+
));
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// A rule body's declarations as [name, value] pairs. A `;` or `:` inside a
|
|
422
|
+
// string or parentheses belongs to the value, so
|
|
423
|
+
// `background: url("data:image/png;base64,...") #dd4249` stays one
|
|
424
|
+
// declaration.
|
|
425
|
+
function splitDeclarations(body) {
|
|
426
|
+
const declarations = [];
|
|
427
|
+
let depth = 0;
|
|
428
|
+
let quote = null;
|
|
429
|
+
let start = 0;
|
|
430
|
+
let colon = -1;
|
|
431
|
+
const text = String(body || "");
|
|
432
|
+
const push = (end) => {
|
|
433
|
+
if (colon !== -1) declarations.push([text.slice(start, colon), text.slice(colon + 1, end)]);
|
|
434
|
+
};
|
|
435
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
436
|
+
const ch = text[i];
|
|
437
|
+
if (ch === "\\") { i += 1; continue; }
|
|
438
|
+
if (quote) { if (ch === quote) quote = null; continue; }
|
|
439
|
+
if (ch === "\"" || ch === "'") quote = ch;
|
|
440
|
+
else if (ch === "(") depth += 1;
|
|
441
|
+
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
442
|
+
else if (ch === ":" && depth === 0 && colon === -1) colon = i;
|
|
443
|
+
else if (ch === ";" && depth === 0) {
|
|
444
|
+
push(i);
|
|
445
|
+
start = i + 1;
|
|
446
|
+
colon = -1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
push(text.length);
|
|
450
|
+
return declarations;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// The colour rules that can supply the CTA label (#535): each button-selector
|
|
454
|
+
// rule that declares `color:`, with the background it declares beside it, if
|
|
455
|
+
// any. The CTA background is known only after mapping, so pairing happens in
|
|
456
|
+
// declaredCtaForegroundValue.
|
|
457
|
+
function collectCtaLabelRules(content, rootTokens = {}) {
|
|
458
|
+
const rules = [];
|
|
459
|
+
for (const match of stripCssComments(content).matchAll(/([^{}]+)\{([^{}]+)\}/g)) {
|
|
460
|
+
if (!isButtonSelector(match[1])) continue;
|
|
461
|
+
let color = null;
|
|
462
|
+
let background = null;
|
|
463
|
+
for (const [rawName, rawValue] of splitDeclarations(match[2])) {
|
|
464
|
+
const name = rawName.trim().toLowerCase();
|
|
465
|
+
const value = resolveDeclarationColor(rawValue, rootTokens);
|
|
466
|
+
if (!value) continue;
|
|
467
|
+
if (name === "color") color = value;
|
|
468
|
+
if (["background", "background-color"].includes(name)) background = value;
|
|
469
|
+
}
|
|
470
|
+
if (color) rules.push({ color, background });
|
|
471
|
+
}
|
|
472
|
+
return rules;
|
|
473
|
+
}
|
|
474
|
+
|
|
301
475
|
function extractDeclarationColor(value) {
|
|
302
476
|
const cleaned = String(value || "").replace(/!important/gi, "").trim();
|
|
303
477
|
const exact = normalizeColor(cleaned);
|
|
@@ -336,6 +510,8 @@ function candidateFromFile(path, role, source = "css_file", referencedBy = []) {
|
|
|
336
510
|
role,
|
|
337
511
|
hash: sha256File(path),
|
|
338
512
|
tokens: { ...inferred, ...parsed.tokens },
|
|
513
|
+
root_tokens: parsed.tokens,
|
|
514
|
+
cta_label_rules: collectCtaLabelRules(content, parsed.tokens),
|
|
339
515
|
warnings: parsed.warnings,
|
|
340
516
|
referenced_by: referencedBy,
|
|
341
517
|
};
|
|
@@ -345,10 +521,16 @@ function inlineCandidatesFromHtml(path, role) {
|
|
|
345
521
|
const content = readFileSync(path, "utf8");
|
|
346
522
|
const candidates = [];
|
|
347
523
|
for (const styleBlock of extractStyleBlocks(content)) {
|
|
524
|
+
const styleBody = stripCssComments(styleBlock.body);
|
|
525
|
+
// Blocks are read from the raw style body so each candidate's index and
|
|
526
|
+
// hash match earlier releases (an unchanged source is not reported stale);
|
|
527
|
+
// a :root block that starts inside a comment is skipped.
|
|
528
|
+
const comments = [...styleBlock.body.matchAll(/\/\*[\s\S]*?\*\//g)].map((match) => [match.index, match.index + match[0].length]);
|
|
348
529
|
for (const block of extractRootBlocks(styleBlock.body)) {
|
|
530
|
+
if (comments.some(([start, end]) => block.offset >= start && block.offset < end)) continue;
|
|
349
531
|
const parsed = parseRootCustomProperties(`:root {${block.body}}`);
|
|
350
532
|
if (Object.keys(parsed.tokens).length === 0) continue;
|
|
351
|
-
const inferred = inferDesignIntentTokens(
|
|
533
|
+
const inferred = inferDesignIntentTokens(styleBody, parsed.tokens);
|
|
352
534
|
candidates.push({
|
|
353
535
|
source: "html_inline_root",
|
|
354
536
|
path,
|
|
@@ -356,6 +538,8 @@ function inlineCandidatesFromHtml(path, role) {
|
|
|
356
538
|
hash: sha256(`${path}:${styleBlock.index}:${block.index}:${block.body}`),
|
|
357
539
|
inline_block_index: styleBlock.index,
|
|
358
540
|
tokens: { ...inferred, ...parsed.tokens },
|
|
541
|
+
root_tokens: parsed.tokens,
|
|
542
|
+
cta_label_rules: collectCtaLabelRules(styleBody, parsed.tokens),
|
|
359
543
|
warnings: parsed.warnings,
|
|
360
544
|
referenced_by: [],
|
|
361
545
|
});
|
|
@@ -488,7 +672,8 @@ function contrastRatio(luminanceA, luminanceB) {
|
|
|
488
672
|
// brand background (yellow/white/pastel) yields a dark foreground; a dark or
|
|
489
673
|
// saturated background yields a light one. This is the fix for white-on-light
|
|
490
674
|
// CTA text: never trust a copied source --text-inverse (which defaults to
|
|
491
|
-
// white)
|
|
675
|
+
// white) unless it is readable on the CTA (declaredForeground below);
|
|
676
|
+
// otherwise derive from the background's luminance.
|
|
492
677
|
function readableForeground(bgValue, choices = {}) {
|
|
493
678
|
const bgRgb = colorToRgb(bgValue);
|
|
494
679
|
if (!bgRgb) return null;
|
|
@@ -507,12 +692,39 @@ function readableForeground(bgValue, choices = {}) {
|
|
|
507
692
|
};
|
|
508
693
|
}
|
|
509
694
|
|
|
695
|
+
// The CTA label colour the source declares (#535). A light scaffold default
|
|
696
|
+
// (white --text-inverse on a yellow CTA) must still lose to the luminance pick,
|
|
697
|
+
// so the declared colour is used only when it clears WCAG AA for large text
|
|
698
|
+
// (3:1) on the CTA background. CTA labels are short, semibold button text, and
|
|
699
|
+
// the design's own pairing is the stronger signal than a marginally higher
|
|
700
|
+
// black/white score (white on #dd4249 is 4.24:1, black 4.67:1). Anything under
|
|
701
|
+
// the contract's normal-text min_contrast_ratio still gets the low-contrast
|
|
702
|
+
// warning below.
|
|
703
|
+
const DECLARED_CTA_FOREGROUND_MIN_CONTRAST = 3;
|
|
704
|
+
const CTA_BACKGROUND_TARGET = "--brand--color--cta-primary";
|
|
705
|
+
|
|
706
|
+
function declaredForeground(bgValue, declaredValue) {
|
|
707
|
+
const bgRgb = colorToRgb(bgValue);
|
|
708
|
+
const declared = normalizeColor(declaredValue);
|
|
709
|
+
if (!bgRgb || !declared) return null;
|
|
710
|
+
const bgLuminance = relativeLuminance(bgRgb);
|
|
711
|
+
const declaredLuminance = relativeLuminance(colorToRgb(declared));
|
|
712
|
+
const contrast = contrastRatio(bgLuminance, declaredLuminance);
|
|
713
|
+
if (contrast < DECLARED_CTA_FOREGROUND_MIN_CONTRAST) return null;
|
|
714
|
+
return {
|
|
715
|
+
value: declared,
|
|
716
|
+
contrast: Math.round(contrast * 100) / 100,
|
|
717
|
+
on: declaredLuminance < bgLuminance ? "dark" : "light",
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
510
721
|
// Emit foreground/on-color tokens derived from the luminance of the background
|
|
511
722
|
// each sits on. Pairing comes from the contract's foreground_derivations so the
|
|
512
723
|
// generator stays data-driven. A foreground target already mapped from a source
|
|
513
724
|
// token is left untouched; one whose paired backgrounds are all unmapped is
|
|
514
|
-
// skipped (no background to read).
|
|
515
|
-
|
|
725
|
+
// skipped (no background to read). A foreground paired with the CTA background
|
|
726
|
+
// takes the source's declared CTA foreground when it is readable there.
|
|
727
|
+
function deriveForegroundMappings(existingMappings, targetTokens, { ctaForeground = null } = {}) {
|
|
516
728
|
const config = targetTokens.foreground_derivations;
|
|
517
729
|
if (!isObject(config) || !isObject(config.derivations)) return { mappings: [], warnings: [] };
|
|
518
730
|
const allowedTargets = new Set(targetTokens.tokens || []);
|
|
@@ -530,12 +742,15 @@ function deriveForegroundMappings(existingMappings, targetTokens) {
|
|
|
530
742
|
const backgroundTarget = candidates.find((target) => valueByTarget.has(target));
|
|
531
743
|
if (!backgroundTarget) continue;
|
|
532
744
|
const backgroundValue = valueByTarget.get(backgroundTarget);
|
|
533
|
-
const
|
|
745
|
+
const declared = backgroundTarget === CTA_BACKGROUND_TARGET ? declaredForeground(backgroundValue, ctaForeground) : null;
|
|
746
|
+
const readable = declared || readableForeground(backgroundValue, choices);
|
|
534
747
|
if (!readable) continue;
|
|
535
748
|
if (minContrast && readable.contrast < minContrast) {
|
|
536
749
|
warnings.push(issue(
|
|
537
750
|
"theme.foreground.low_contrast",
|
|
538
|
-
|
|
751
|
+
declared
|
|
752
|
+
? `Declared CTA foreground ${declared.value} for ${foregroundTarget} on ${backgroundTarget} (${backgroundValue}) reaches ${readable.contrast}:1 contrast: it clears ${DECLARED_CTA_FOREGROUND_MIN_CONTRAST}:1 for large text but not ${minContrast}:1 for normal text. Confirm the CTA label is large or bold text.`
|
|
753
|
+
: `Derived ${foregroundTarget} on ${backgroundTarget} (${backgroundValue}) only reaches ${readable.contrast}:1 contrast (< ${minContrast}:1). Confirm the brand background or supply an explicit foreground.`,
|
|
539
754
|
{ foreground: foregroundTarget, background: backgroundTarget, background_value: backgroundValue, contrast: readable.contrast },
|
|
540
755
|
));
|
|
541
756
|
}
|
|
@@ -547,7 +762,9 @@ function deriveForegroundMappings(existingMappings, targetTokens) {
|
|
|
547
762
|
target: foregroundTarget,
|
|
548
763
|
value: readable.value,
|
|
549
764
|
confidence,
|
|
550
|
-
derivation:
|
|
765
|
+
derivation: declared
|
|
766
|
+
? { method: "declared-cta-foreground", background: backgroundTarget, background_value: backgroundValue, declared_value: declared.value, on: readable.on, contrast: readable.contrast }
|
|
767
|
+
: { method: "foreground-from-luminance", background: backgroundTarget, background_value: backgroundValue, on: readable.on, contrast: readable.contrast },
|
|
551
768
|
});
|
|
552
769
|
}
|
|
553
770
|
return { mappings, warnings };
|
|
@@ -618,7 +835,69 @@ function collectTokenConflicts(candidates) {
|
|
|
618
835
|
return conflicts;
|
|
619
836
|
}
|
|
620
837
|
|
|
621
|
-
|
|
838
|
+
// A declared text token (#535) is a :root custom property in the selected
|
|
839
|
+
// source whose name has a "text" part and whose value is a solid colour,
|
|
840
|
+
// minus the names with another job: inverse / on-colour labels, secondary,
|
|
841
|
+
// muted or state copy (links, status colours, placeholders, selections),
|
|
842
|
+
// CTA/button labels, and text shadows, borders and backgrounds.
|
|
843
|
+
const BODY_TEXT_MIN_CONTRAST = 4.5;
|
|
844
|
+
|
|
845
|
+
function isDeclaredTextToken(name) {
|
|
846
|
+
if (isTargetContractToken(name)) return false;
|
|
847
|
+
const parts = tokenNameParts(name);
|
|
848
|
+
if (!hasTokenPart(parts, ["text"]) || isTextLabelToken(parts)) return false;
|
|
849
|
+
return !hasTokenPart(parts, [
|
|
850
|
+
"secondary", "muted", "subtle", "cta", "button", "btn",
|
|
851
|
+
"link", "error", "danger", "success", "warning", "info", "highlight", "accent",
|
|
852
|
+
"placeholder", "disabled", "selection",
|
|
853
|
+
"shadow", "border", "outline", "stroke", "bg", "background",
|
|
854
|
+
]);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Body text takes the darkest declared text token that is darker than the body
|
|
858
|
+
// background and reaches WCAG AA for normal text (4.5:1) on it, whether or not
|
|
859
|
+
// a --text-primary was otherwise found. With no solid body background, or no
|
|
860
|
+
// token that qualifies, the caller keeps its existing --text-primary pick (or
|
|
861
|
+
// none).
|
|
862
|
+
function darkestDeclaredBodyText(rootTokens, bodyBackground) {
|
|
863
|
+
const bgRgb = colorToRgb(bodyBackground);
|
|
864
|
+
if (!bgRgb) return null;
|
|
865
|
+
const bgLuminance = relativeLuminance(bgRgb);
|
|
866
|
+
let best = null;
|
|
867
|
+
for (const [name, rawValue] of Object.entries(rootTokens || {})) {
|
|
868
|
+
if (!isDeclaredTextToken(name)) continue;
|
|
869
|
+
const value = normalizeColor(rawValue);
|
|
870
|
+
if (!value) continue;
|
|
871
|
+
const luminance = relativeLuminance(colorToRgb(value));
|
|
872
|
+
if (luminance >= bgLuminance) continue;
|
|
873
|
+
const contrast = contrastRatio(bgLuminance, luminance);
|
|
874
|
+
if (contrast < BODY_TEXT_MIN_CONTRAST) continue;
|
|
875
|
+
if (!best || luminance < best.luminance) best = { name, value, luminance, contrast: Math.round(contrast * 100) / 100 };
|
|
876
|
+
}
|
|
877
|
+
return best;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// The CTA label colour the source declares (#535), in this order: the colour
|
|
881
|
+
// every button-selector rule on the CTA background agrees on (the design's own
|
|
882
|
+
// pairing); a :root inverse / on-colour text token (--text-inverse first); the
|
|
883
|
+
// colour every other button-selector rule agrees on. Button rules that
|
|
884
|
+
// disagree declare nothing at their step. null when none applies, so the
|
|
885
|
+
// luminance pick stands.
|
|
886
|
+
function declaredCtaForegroundValue({ rootTokens = {}, ctaLabelRules = [], ctaBackground = null }) {
|
|
887
|
+
const background = normalizeColor(ctaBackground);
|
|
888
|
+
if (!background) return null;
|
|
889
|
+
const paired = [...new Set(ctaLabelRules.filter((rule) => rule.background === background).map((rule) => rule.color))];
|
|
890
|
+
if (paired.length === 1) return paired[0];
|
|
891
|
+
const labelNames = Object.keys(rootTokens || {}).filter((name) => (
|
|
892
|
+
!isTargetContractToken(name) && isTextLabelToken(tokenNameParts(name)) && normalizeColor(rootTokens[name])
|
|
893
|
+
));
|
|
894
|
+
const labelName = labelNames.includes("--text-inverse") ? "--text-inverse" : labelNames[0];
|
|
895
|
+
if (labelName) return normalizeColor(rootTokens[labelName]);
|
|
896
|
+
const unpaired = [...new Set(ctaLabelRules.filter((rule) => !rule.background).map((rule) => rule.color))];
|
|
897
|
+
return unpaired.length === 1 ? unpaired[0] : null;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function mapTokens(tokens, targetTokens, { rootTokens = {}, ctaLabelRules = [] } = {}) {
|
|
622
901
|
const allowedTargets = new Set(targetTokens.tokens || []);
|
|
623
902
|
const mappings = [];
|
|
624
903
|
const warnings = [];
|
|
@@ -632,9 +911,28 @@ function mapTokens(tokens, targetTokens) {
|
|
|
632
911
|
mappings.push({ source, target, value, confidence, derivation });
|
|
633
912
|
}
|
|
634
913
|
|
|
914
|
+
// Applies whether or not the source yields a --text-primary; a --text-primary
|
|
915
|
+
// of the same colour keeps its plain mapping.
|
|
916
|
+
const textPrimary = isNonEmptyString(tokens["--text-primary"]) ? tokens["--text-primary"].trim() : null;
|
|
917
|
+
const textPrimaryColor = normalizeColor(textPrimary);
|
|
918
|
+
const bodyText = darkestDeclaredBodyText(rootTokens, tokens["--surface-bg"]);
|
|
919
|
+
const replaceBodyText = Boolean(bodyText) && bodyText.value !== textPrimaryColor;
|
|
920
|
+
|
|
635
921
|
for (const [source, targets] of Object.entries(sourceMappings)) {
|
|
636
|
-
|
|
637
|
-
|
|
922
|
+
const declaredBodyText = source === "--text-primary" && replaceBodyText;
|
|
923
|
+
if (!declaredBodyText && !isNonEmptyString(tokens[source])) continue;
|
|
924
|
+
for (const target of targets) {
|
|
925
|
+
if (declaredBodyText) {
|
|
926
|
+
addMapping(bodyText.name, target, bodyText.value, "high", {
|
|
927
|
+
method: "darkest-declared-text-token",
|
|
928
|
+
replaced_value: textPrimary,
|
|
929
|
+
background_value: normalizeColor(tokens["--surface-bg"]),
|
|
930
|
+
contrast: bodyText.contrast,
|
|
931
|
+
});
|
|
932
|
+
} else {
|
|
933
|
+
addMapping(source, target, tokens[source].trim());
|
|
934
|
+
}
|
|
935
|
+
}
|
|
638
936
|
}
|
|
639
937
|
|
|
640
938
|
const derived = targetTokens.derived_mappings?.["--brand-primary"] || {};
|
|
@@ -671,7 +969,9 @@ function mapTokens(tokens, targetTokens) {
|
|
|
671
969
|
|
|
672
970
|
// Foreground/on-color tokens derive from the luminance of the background they
|
|
673
971
|
// sit on (CTA, primary, accent), after all backgrounds are mapped above.
|
|
674
|
-
const
|
|
972
|
+
const ctaBackground = mappings.find((mapping) => mapping.target === CTA_BACKGROUND_TARGET)?.value;
|
|
973
|
+
const ctaForeground = declaredCtaForegroundValue({ rootTokens, ctaLabelRules, ctaBackground });
|
|
974
|
+
const foreground = deriveForegroundMappings(mappings, targetTokens, { ctaForeground });
|
|
675
975
|
for (const mapping of foreground.mappings) {
|
|
676
976
|
addMapping(mapping.source, mapping.target, mapping.value, mapping.confidence, mapping.derivation);
|
|
677
977
|
}
|
|
@@ -891,7 +1191,7 @@ export function inspectBrandTheme({ packet, packetPath, context = null, policy =
|
|
|
891
1191
|
warnings.push(issue("theme.source_tokens.unresolved", `Source token ${token} uses an unresolved value; it cannot be compared to producer defaults.`));
|
|
892
1192
|
}
|
|
893
1193
|
|
|
894
|
-
const mapped = selected ? mapTokens(selected.tokens, contracts.targetTokens) : { mappings: [], warnings: [] };
|
|
1194
|
+
const mapped = selected ? mapTokens(selected.tokens, contracts.targetTokens, { rootTokens: selected.root_tokens, ctaLabelRules: selected.cta_label_rules }) : { mappings: [], warnings: [] };
|
|
895
1195
|
warnings.push(...mapped.warnings);
|
|
896
1196
|
const confidence = confidenceFor({ selected, defaultMatch, mappings: mapped.mappings, conflicts });
|
|
897
1197
|
const css = selected && mapped.mappings.length > 0 ? renderBrandThemeCss({ selected, mappings: mapped.mappings, confidence }) : null;
|
package/src/built-site-scope.mjs
CHANGED
|
@@ -18,6 +18,18 @@ import { basename, join, relative, sep } from "node:path";
|
|
|
18
18
|
|
|
19
19
|
const HTML_EXT = ".html";
|
|
20
20
|
|
|
21
|
+
// The route tokens inferPageType reads for the funnel roles, exported so a
|
|
22
|
+
// caller can tell which token produced a guess (#529: an explicit "upsell"
|
|
23
|
+
// word is a stronger signal than "oto"). Tested against the lower-cased,
|
|
24
|
+
// trimmed route.
|
|
25
|
+
export const ROUTE_TOKENS = Object.freeze({
|
|
26
|
+
downsell: /down[\s_/-]*sell/,
|
|
27
|
+
upsell: /up[\s_/-]*sell/,
|
|
28
|
+
one_time_offer: /(^|[\s_/-])oto([\s_/-]|\d|$)|one[\s_/-]*time[\s_/-]*offer/,
|
|
29
|
+
receipt: /thank|receipt|confirm(ation)?|order[\s_/-]*complete/,
|
|
30
|
+
checkout: /checkout|\bcart\b|\border\b/,
|
|
31
|
+
});
|
|
32
|
+
|
|
21
33
|
// Funnel page-type inference from a built route or filename. Order matters:
|
|
22
34
|
// downsell is tested before upsell, and the broad fallbacks (landing/page) run
|
|
23
35
|
// last. Returns one of the page types QA understands; "page" for generic
|
|
@@ -25,10 +37,10 @@ const HTML_EXT = ".html";
|
|
|
25
37
|
export function inferPageType(routeOrName) {
|
|
26
38
|
const value = String(routeOrName || "").toLowerCase().trim();
|
|
27
39
|
if (value === "" || value === "/" || value === "index") return "landing";
|
|
28
|
-
if (
|
|
29
|
-
if (
|
|
30
|
-
if (
|
|
31
|
-
if (
|
|
40
|
+
if (ROUTE_TOKENS.downsell.test(value)) return "downsell";
|
|
41
|
+
if (ROUTE_TOKENS.upsell.test(value) || ROUTE_TOKENS.one_time_offer.test(value)) return "upsell";
|
|
42
|
+
if (ROUTE_TOKENS.receipt.test(value)) return "receipt";
|
|
43
|
+
if (ROUTE_TOKENS.checkout.test(value)) return "checkout";
|
|
32
44
|
// The two-step bundle-selection step. Deliberately narrow, and anchored on
|
|
33
45
|
// BOTH ends: the route must *be* about choosing a bundle, not merely contain
|
|
34
46
|
// the words. An editorial "/our-choose-bundle-guide/" stays generic rather
|