@tokenoftrust/cli 2.5.5 → 2.5.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.5.5",
3
+ "version": "2.5.6",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -39,7 +39,7 @@ export const USAGE = `tot report go-live — the go-live readiness report for yo
39
39
 
40
40
  Save each page's JSON from the devkit page checks into <dir> first, e.g.
41
41
  validate-page.sh <base> / > <dir>/home.json
42
- validate-page-browser.mjs <base> / --provider cloudflare > <dir>/home.browser.json
42
+ validate-page-browser.mjs <base> / --store . --provider cloudflare > <dir>/home.browser.json
43
43
 
44
44
  ${TEASER}`;
45
45
 
@@ -147,9 +147,9 @@ export function storeConfigCheck(lint) {
147
147
  /**
148
148
  * Everything the report needs, from a page-results directory. A site-wide run's summary is used as
149
149
  * is; otherwise the checks are derived here from the single-page results.
150
- * @param {{ pagesDir: string, stylePath?: string|null, lint?: any }} input
150
+ * @param {{ pagesDir: string, stylePath?: string|null, lint?: any, config?: Record<string, any>|null }} input the store checkout's lint and `.tot/config.json`
151
151
  */
152
- export function evidenceFromDir({ pagesDir, stylePath = null, lint = null }) {
152
+ export function evidenceFromDir({ pagesDir, stylePath = null, lint = null, config = null }) {
153
153
  const summary = readJson(join(pagesDir, "site-summary.json"));
154
154
  const styleJson = readJson(stylePath ?? join(pagesDir, "style.json"));
155
155
  const browserDir = join(pagesDir, "browser");
@@ -177,6 +177,7 @@ export function evidenceFromDir({ pagesDir, stylePath = null, lint = null }) {
177
177
  aggregate: { ...summary, totals: { ...summary.totals, issues: summary.totals?.issues ?? { lowRiskOpen: 0 } } },
178
178
  styleJson: styleJson ?? (summary.style?.gates ? { gates: summary.style.gates } : null),
179
179
  args: { manifest: summary.manifest ?? "(none supplied to the style-concept audit)" },
180
+ config,
180
181
  }),
181
182
  ];
182
183
  return {
@@ -195,6 +196,7 @@ export function evidenceFromDir({ pagesDir, stylePath = null, lint = null }) {
195
196
  aggregate: { pages: results, totals: { issues: { lowRiskOpen: 0 } } },
196
197
  styleJson,
197
198
  args: { manifest: "(none supplied to the style-concept audit)" },
199
+ config,
198
200
  }).filter((c) => c.area !== "Low-risk fix loop"),
199
201
  ];
200
202
  const failing = results.filter((r) => Object.values(r.gates).some((v) => v === false)).length;
@@ -288,7 +290,8 @@ export function run(argv, ctx, io = {}) {
288
290
  return 2;
289
291
  }
290
292
  const lint = storeExists ? validateTenant(store, { tenantId: tenant, mode: args.workspace ? "workspace" : "monorepo" }) : null;
291
- const evidence = evidenceFromDir({ pagesDir, stylePath: args.style ? resolve(args.style) : null, lint });
293
+ const config = storeExists ? readJson(join(store, ".tot", "config.json")) : null;
294
+ const evidence = evidenceFromDir({ pagesDir, stylePath: args.style ? resolve(args.style) : null, lint, config });
292
295
  if (!evidence.measured) {
293
296
  err(`${fail(
294
297
  `no page-check results under ${pagesDir}`,
@@ -59,7 +59,10 @@ export const NICOTINE_WARNING_TEXT: string;
59
59
  export const CONSPICUITY_FLOOR: Readonly<{ contrast: number; fontSizePx: number; fontWeight: number }>;
60
60
  export const DEFAULT_NICOTINE_WARNING_PRESET: string;
61
61
  export const NICOTINE_WARNING_PRESETS: Readonly<Record<string, Readonly<NicotineWarningPresentation>>>;
62
+ export const NICOTINE_WARNING_KEYS: readonly string[];
62
63
  export const NICOTINE_WARNING_STYLE_KEYS: readonly string[];
64
+ export const NICOTINE_WARNING_CHECK_WIDTH_BOUNDS: Readonly<{ min: number; max: number }>;
65
+ export function nicotineWarningCheckWidths(declaration: unknown): { widths: number[]; problems: string[] };
63
66
  export function contrastRatio(a: string, b: string): number;
64
67
  export function conspicuityProblems(p: NicotineWarningPresentation): string[];
65
68
  export function resolveNicotineWarning(declaration: unknown): ResolvedNicotineWarning;
@@ -12,7 +12,9 @@
12
12
  *
13
13
  * Mirrors packages/public-runtime/src/declared-tenant-config.mjs (this package ships
14
14
  * to npm on its own); test/declared-config-parity.test.mjs pins the two together.
15
- * Dependency-free: the storefront runner copies this file as-is.
15
+ * Dependency-free: the storefront runner copies this file as-is, and the devkit page checks carry
16
+ * a byte-identical copy (docs/devkit-staging/tools/lib/declared-config.mjs, pinned by
17
+ * test/declared-config-devkit-copy.test.mjs) so their gates read a store's declaration the same way.
16
18
  */
17
19
 
18
20
  export const TOT_CONFIG_KEYS = [
@@ -184,6 +186,7 @@ export const MANDATED_NOTICE_KEYS = [
184
186
 
185
187
  const ERROR = "error";
186
188
  const WARN = "warn";
189
+ /** @type {(v: unknown) => v is Record<string, any>} */
187
190
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
188
191
 
189
192
  /**
@@ -470,7 +473,13 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
470
473
  }
471
474
  }
472
475
  out.push(...complianceFindings(config, file));
473
- const warning = resolveNicotineWarning(isObject(config.compliance) ? config.compliance.nicotineWarning : undefined);
476
+ const declaration = isObject(config.compliance) ? config.compliance.nicotineWarning : undefined;
477
+ for (const problem of nicotineWarningCheckWidths(declaration).problems) {
478
+ out.push(mk(ERROR, "config-nicotine-warning-check-widths", `${file} compliance`,
479
+ `\`compliance.nicotineWarning\`: ${problem}. The page browser gate cannot check the warning at the widths you meant.`,
480
+ "list the widths where your layout changes (e.g. where your header becomes fixed), or remove `checkWidthsPx`"));
481
+ }
482
+ const warning = resolveNicotineWarning(declaration);
474
483
  if (warning.enabled && warning.problems.length > 0) {
475
484
  out.push(mk(ERROR, "config-nicotine-warning", `${file} compliance`, nicotineWarningProblemMessage(warning.problems),
476
485
  'use `true`, `{ "preset": "dark" }` or `{ "preset": "light" }`, or fix the style values named above'));
@@ -504,6 +513,7 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
504
513
  // "nicotineWarning": true → the "dark" preset
505
514
  // "nicotineWarning": { "preset": "light" } → a validated preset
506
515
  // "nicotineWarning": { "preset": "light", "style": {...} } → a custom presentation
516
+ // "nicotineWarning": { "checkWidthsPx": [800] } → also checked seen at 800px wide
507
517
  //
508
518
  // Every presentation — preset or custom — must clear CONSPICUITY_FLOOR. A preset needs no
509
519
  // review. A custom presentation is a deviation the owner approves at go-live:
@@ -562,6 +572,9 @@ export const NICOTINE_WARNING_PRESETS = Object.freeze({
562
572
  * }} NicotineWarningPresentation
563
573
  */
564
574
 
575
+ /** Every key a `compliance.nicotineWarning` object may declare. */
576
+ export const NICOTINE_WARNING_KEYS = Object.freeze(["preset", "style", "checkWidthsPx"]);
577
+
565
578
  export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
566
579
  "background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
567
580
  ]);
@@ -669,8 +682,9 @@ export function resolveNicotineWarning(declaration) {
669
682
  if (!isObject(declaration)) {
670
683
  return resolved({ problems: ["must be `true` or an object like { \"preset\": \"light\" }"] });
671
684
  }
672
- const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, ...rest } = /** @type {Record<string, unknown>} */ (declaration);
673
- const problems = Object.keys(rest).map((k) => `\`${k}\` is not a nicotineWarning key (allowed: preset, style)`);
685
+ const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, checkWidthsPx: _checkWidths, ...rest } =
686
+ /** @type {Record<string, unknown>} */ (declaration);
687
+ const problems = Object.keys(rest).map((k) => `\`${k}\` is not a nicotineWarning key (allowed: ${NICOTINE_WARNING_KEYS.join(", ")})`);
674
688
  const base = typeof presetName === "string" ? NICOTINE_WARNING_PRESETS[presetName] : undefined;
675
689
  if (!base) {
676
690
  problems.push(`\`preset\` must be one of ${Object.keys(NICOTINE_WARNING_PRESETS).map((p) => `"${p}"`).join(", ")}`);
@@ -695,6 +709,35 @@ export function resolveNicotineWarning(declaration) {
695
709
  return resolved({ presentation, preset: matching ?? null, custom: !matching });
696
710
  }
697
711
 
712
+ /** The viewport widths a store may add to the page browser gate's nicotine-warning check. */
713
+ export const NICOTINE_WARNING_CHECK_WIDTH_BOUNDS = Object.freeze({ min: 320, max: 3840 });
714
+
715
+ /**
716
+ * The extra viewport widths at which the page browser gate checks the nicotine warning is seen.
717
+ *
718
+ * The gate always checks its own phone, tablet, laptop and desktop widths; `checkWidthsPx` adds
719
+ * the store's own breakpoints (e.g. the width where its header becomes fixed) and never removes
720
+ * a default. A store that does not declare the warning has no widths; `true` and an object
721
+ * without `checkWidthsPx` add none.
722
+ *
723
+ * "nicotineWarning": { "preset": "dark", "checkWidthsPx": [800, 1180] }
724
+ *
725
+ * `problems` lists why a declared value is unusable, in which case no extra width is used.
726
+ * @param {unknown} declaration the store's `compliance.nicotineWarning`
727
+ * @returns {{ widths: number[], problems: string[] }}
728
+ */
729
+ export function nicotineWarningCheckWidths(declaration) {
730
+ if (!isObject(declaration)) return { widths: [], problems: [] };
731
+ const value = /** @type {Record<string, unknown>} */ (declaration).checkWidthsPx;
732
+ if (value === undefined) return { widths: [], problems: [] };
733
+ const { min, max } = NICOTINE_WARNING_CHECK_WIDTH_BOUNDS;
734
+ const usable = (/** @type {unknown} */ w) => Number.isInteger(w) && /** @type {number} */ (w) >= min && /** @type {number} */ (w) <= max;
735
+ if (!Array.isArray(value) || value.length === 0 || !value.every(usable)) {
736
+ return { widths: [], problems: [`\`checkWidthsPx\` must be a non-empty array of whole pixel widths from ${min} to ${max}, e.g. [800]`] };
737
+ }
738
+ return { widths: [...new Set(/** @type {number[]} */ (value))].sort((a, b) => a - b), problems: [] };
739
+ }
740
+
698
741
  /**
699
742
  * The exact, human-readable statement of a custom presentation — what the owner approves.
700
743
  * Every resolved value is in it, so two presentations that differ in any value describe
@@ -720,7 +763,8 @@ export function describeNicotineWarningPresentation(p) {
720
763
  * fixed or sticky header pinned to the top of the viewport). Raising the warning above page
721
764
  * chrome would paint it over the tenant's own open menus and drawers, so occlusion is measured
722
765
  * instead: the page browser gate (`validate-page-browser.mjs`, `nicotineWarning`) fails a page
723
- * whose warning is covered at a phone, tablet or desktop width and names the covering element.
766
+ * whose warning is covered at a phone, tablet or desktop width, or at a width the store declares
767
+ * in `checkWidthsPx`, and names the covering element.
724
768
  * @param {unknown} declaration the tenant's `compliance.nicotineWarning`
725
769
  * @param {"bar" | "block"} variant
726
770
  */
@@ -178,8 +178,38 @@ export function ecommerceStyleGuideCheck(styleJson) {
178
178
  };
179
179
  }
180
180
 
181
- export function buildReadinessChecks({ aggregate, styleJson, args }) {
181
+ /**
182
+ * The store's declared compliance notices, judged on every page. Only a notice the store declares
183
+ * is a row, and a page that did not judge it fails: a declared legal notice nobody has seen on the
184
+ * page is not ready, whatever the page check reported.
185
+ * @param {{ gates?: Record<string, boolean | null> }[]} results
186
+ * @param {Record<string, any> | null | undefined} config the store's `.tot/config.json`
187
+ */
188
+ export function complianceNoticesCheck(results, config) {
189
+ if (!config?.compliance?.nicotineWarning) return null;
190
+ const counts = countGate(results, "nicotineWarning");
191
+ const problem = counts.total === 0
192
+ ? "No pages were discovered for validation."
193
+ : counts.fail > 0 || counts.unknown > 0
194
+ ? `The store declares the FDA nicotine warning: ${counts.fail}/${counts.total} page(s) fail the visibility gate` +
195
+ `${counts.unknown > 0 ? ` and ${counts.unknown}/${counts.total} were not judged against the declaration` : ""}.`
196
+ : `The FDA nicotine warning is seen on all ${counts.total} page(s) at every checked width.`;
197
+ return {
198
+ area: "Compliance notices",
199
+ status: counts.total > 0 && counts.fail === 0 && counts.unknown === 0 ? "PASS" : "FAIL",
200
+ why: "A store that declares the FDA nicotine warning must show it, conspicuously and uncovered, on every page (21 CFR 1143.3).",
201
+ problem,
202
+ fix: "Run the browser page checks with --store <your checkout> on every page, then offset any fixed or sticky header the gate names; never restyle or move the warning.",
203
+ };
204
+ }
205
+
206
+ /**
207
+ * @param {{ aggregate: any, styleJson: any, args: { manifest: string }, config?: Record<string, any> | null }} input
208
+ * `config` is the store's `.tot/config.json`, which decides the compliance notices row.
209
+ */
210
+ export function buildReadinessChecks({ aggregate, styleJson, args, config = null }) {
182
211
  const results = aggregate.pages;
212
+ const compliance = complianceNoticesCheck(results, config);
183
213
  const render = countGate(results, "render");
184
214
  const boundary = countGate(results, "boundary");
185
215
  const seo = combineCounts(countGate(results, "seo"), countGate(results, "sitemap"));
@@ -191,6 +221,7 @@ export function buildReadinessChecks({ aggregate, styleJson, args }) {
191
221
  const lowRisk = aggregate.totals.issues.lowRiskOpen ?? 0;
192
222
 
193
223
  return [
224
+ ...(compliance ? [compliance] : []),
194
225
  styleGuideCheck(styleJson, args.manifest),
195
226
  ecommerceStyleGuideCheck(styleJson),
196
227
  styleQualityCheck(styleJson),