@tokenoftrust/storefront-runner 2.0.4 → 2.0.5

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.
Files changed (46) hide show
  1. package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
  2. package/apps/storefront/node_modules/.bin/vitest +2 -2
  3. package/apps/storefront/node_modules/.vite/deps/_metadata.json +19 -19
  4. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +3 -3
  5. package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
  6. package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
  7. package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
  8. package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
  9. package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
  10. package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
  11. package/apps/storefront/src/layouts/Layout.astro +64 -6
  12. package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
  13. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
  14. package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
  15. package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
  16. package/apps/storefront/src/lib/rawChrome.ts +73 -14
  17. package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
  18. package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
  19. package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
  20. package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
  21. package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
  22. package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
  23. package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
  24. package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
  25. package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
  26. package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
  27. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
  28. package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
  29. package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
  30. package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
  31. package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
  32. package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
  33. package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
  34. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
  35. package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
  36. package/package.json +1 -1
  37. package/packages/public-runtime/node_modules/.bin/vitest +2 -2
  38. package/packages/public-runtime/src/chrome.ts +18 -7
  39. package/packages/public-runtime/src/index.ts +10 -0
  40. package/packages/public-runtime/src/integration/index.ts +1 -0
  41. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
  42. package/packages/public-runtime/src/interaction-tracking.ts +364 -0
  43. package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
  44. package/scripts/tenant/validate.mjs +306 -1
  45. package/scripts/tot-dev.mjs +59 -3
  46. package/tenants/home/public/js/storefront.js +6 -87
@@ -168,6 +168,25 @@ function validateRawHtmlBody(html) {
168
168
  // step with packages/cli/src/validate.mjs's identical copy.
169
169
  const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal"]);
170
170
  const CHROME_FOOTER_VARIANTS = new Set(["default"]);
171
+ /**
172
+ * The declarative-interaction-tracking rule, mirrored: every actionable item —
173
+ * an object carrying both `id` and `href` — declares EXACTLY ONE of a non-empty
174
+ * `action` (its stable intent) or `tracking: "none"` (an explicit opt-out).
175
+ * Declaring neither or both is a violation, and nothing is derived from `id`.
176
+ * Contract: docs/architecture/interaction-tracking-attribute-contract.md
177
+ */
178
+ function chromeTrackingDeclared(value) {
179
+ if (Array.isArray(value)) return value.every(chromeTrackingDeclared);
180
+ if (value == null || typeof value !== "object") return true;
181
+ if (typeof value.id === "string" && typeof value.href === "string") {
182
+ const hasAction = typeof value.action === "string" && value.action.trim() !== "";
183
+ const optedOut = value.tracking === "none";
184
+ if (hasAction === optedOut) return false;
185
+ if (value.tracking !== undefined && !optedOut) return false;
186
+ }
187
+ return Object.values(value).every(chromeTrackingDeclared);
188
+ }
189
+
171
190
  export function looksLikeChromeConfig(value) {
172
191
  if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
173
192
  const header = value.header;
@@ -180,6 +199,7 @@ export function looksLikeChromeConfig(value) {
180
199
  if (footer == null || typeof footer !== "object" || Array.isArray(footer)) return false;
181
200
  if (!CHROME_FOOTER_VARIANTS.has(footer.variant)) return false;
182
201
  if (!Array.isArray(footer.columns)) return false;
202
+ if (!chromeTrackingDeclared(value)) return false;
183
203
  return true;
184
204
  }
185
205
 
@@ -410,6 +430,278 @@ function validateHomeDoc(doc, file) {
410
430
  return out;
411
431
  }
412
432
 
433
+ // --- CTA / actionable-identity advisory rules (unit dt-validator-cta-rules) -
434
+ // Three ADVISORY warnings (never block) that a schema pass alone doesn't
435
+ // reach. `validateChromeConfig` (@tot/public-runtime chrome.ts) already
436
+ // HARD-FAILS the governed chrome.json path on the either/or action/
437
+ // tracking:"none" rule (interaction-tracking.ts validateTracking) — this
438
+ // validator does not re-run that schema (deep per-item errors are the
439
+ // write-path's job, per the looksLikeChromeConfig comment above). These three
440
+ // catch what the schema pass doesn't:
441
+ // - `duplicate-cta` — a copy-paste mistake that still PASSES schema (two
442
+ // actionable items sharing one governed identity).
443
+ // - `cta-missing-id` — tenant-authored raw HTML fragments carry NO
444
+ // `data-tot-*` at all pre-derivation (see docs/architecture/interaction-
445
+ // tracking-attribute-contract.md "Deriving data-tot-el") — there is no
446
+ // schema here for validateChromeConfig to fail.
447
+ // - `cta-id-drift` — `data-tot-el` is a PERMANENT identity per
448
+ // dt-contract-v1's decision ledger ("a change here is DRIFT and is a
449
+ // defect"); this one needs a PRIOR version to compare against, which a
450
+ // single-snapshot validateTenant() run doesn't have on its own — see
451
+ // `opts.previousChromeJson`, below.
452
+
453
+ // --- "assign stable tracking ids" — the one named, actionable step (unit
454
+ // dt-assign-ids-step). The three rules above are DETECTION; this is the single
455
+ // place every touchpoint (`tot validate`, `tot submit`/`tot preview`, and the
456
+ // `tot dev` save-loop nudge — scripts/dev/checkout-watch.mjs) points an author
457
+ // at the SAME fix in the SAME words, so it reads as one coherent nudge no
458
+ // matter which command surfaced it. The mechanism already exists (derivation:
459
+ // dt-id-derivation; writeback: dt-id-writeback,
460
+ // apps/storefront/src/pages/api/admin/tracking-writeback.ts) — this names the
461
+ // step and points at it, it invents nothing. Kept in step with
462
+ // packages/cli/src/validate.mjs's identical copy of this block.
463
+ export const TRACKING_ID_STEP_NAME = "assign stable tracking ids";
464
+ export const CTA_MISSING_ID_FIX =
465
+ "assign stable tracking ids: GET/POST /api/admin/tracking-writeback proposes a data-tot-el for every element that's missing one, as a reviewable candidate PR (GET first for a dry-run preview) — or run `tot validate` to see the full list before you submit.";
466
+
467
+ /**
468
+ * The advisory rules LOUD enough to call out by NAME at `tot validate` / `tot
469
+ * submit` / `tot preview` — none of them block, but each ships a page that
470
+ * looks perfectly healthy (clean build, every other check green) while
471
+ * quietly losing something. `[rule, headline, what it costs if ignored]`.
472
+ * ONE place so every surface prints identical wording; `printLoudAdvisories`
473
+ * is the shared renderer.
474
+ */
475
+ export const LOUD_ADVISORY_RULES = [
476
+ [
477
+ "git-conflict-markers",
478
+ "git conflict markers in submitted content — an unfinished merge/rebase?",
479
+ "The preview will still build, but it will serve the broken markers. Resolve before shipping.",
480
+ ],
481
+ [
482
+ "duplicate-skip-link",
483
+ "duplicate skip link — your chrome already supplies one",
484
+ "The page renders fine and every automated check passes; a screen-reader user hears it twice.",
485
+ ],
486
+ [
487
+ "duplicate-cta",
488
+ "duplicate CTA identity — two actionable items share one data-tot-el",
489
+ "Both elements still work, but the click listener and every downstream analytics query treat them as ONE interaction — you lose the ability to tell them apart.",
490
+ ],
491
+ [
492
+ "cta-missing-id",
493
+ `actionable element with no governed identity (data-tot-el) — needs to ${TRACKING_ID_STEP_NAME}`,
494
+ `It renders and works fine, but it's invisible to interaction tracking — nothing about it is ever recorded. ${CTA_MISSING_ID_FIX}`,
495
+ ],
496
+ [
497
+ "cta-id-drift",
498
+ "a previously-declared data-tot-el changed value",
499
+ "The page still works, but everything already recorded under the old id is now orphaned — this identity is supposed to be permanent.",
500
+ ],
501
+ ];
502
+
503
+ /**
504
+ * Print the LOUD-but-advisory findings as named, grouped callouts — see
505
+ * packages/cli/src/validate.mjs's identical copy for the full rationale.
506
+ * @param {Finding[]} findings
507
+ * @param {{ log?: (s: string) => void }} [opts]
508
+ */
509
+ export function printLoudAdvisories(findings, { log = (s) => console.error(s) } = {}) {
510
+ for (const [rule, headline, consequence] of LOUD_ADVISORY_RULES) {
511
+ const hits = findings.filter((f) => f.rule === rule);
512
+ if (!hits.length) continue;
513
+ log(`\n⚠ ${headline} (${hits.length} finding(s))`);
514
+ for (const f of hits) log(` ⚠ ${f.file} — ${f.message}`);
515
+ log(` ${consequence}\n`);
516
+ }
517
+ }
518
+
519
+ /** Every declared actionable `id` in a ChromeConfig, with a path for
520
+ * messages — the SAME field rawChrome.ts / SiteHeader.astro stamp verbatim as
521
+ * `data-tot-el` (ctaIdentityAttr for header.ctas; the direct `id` field on
522
+ * every other actionable shape — see @tot/public-runtime chrome.ts's
523
+ * ChromeLink/ChromeNavItem/ChromeCta/ChromeActionLink), so a collision here
524
+ * is a collision of GOVERNED IDENTITY, not just of an authoring label. Walks
525
+ * the same shapes validateChromeConfig itself walks. Pure.
526
+ */
527
+ function collectChromeActionableIds(config) {
528
+ const out = [];
529
+ const push = (id, path) => {
530
+ if (typeof id === "string" && id.trim()) out.push({ id: id.trim(), path });
531
+ };
532
+ const header = config?.header;
533
+ if (header && typeof header === "object" && !Array.isArray(header)) {
534
+ for (const [i, n] of (Array.isArray(header.nav) ? header.nav : []).entries()) {
535
+ if (n == null || typeof n !== "object") continue;
536
+ push(n.id, `header.nav[${i}]`);
537
+ for (const [j, c] of (Array.isArray(n.children) ? n.children : []).entries()) {
538
+ if (c && typeof c === "object") push(c.id, `header.nav[${i}].children[${j}]`);
539
+ }
540
+ for (const [j, col] of (Array.isArray(n.columns) ? n.columns : []).entries()) {
541
+ if (col == null || typeof col !== "object") continue;
542
+ for (const [k, l] of (Array.isArray(col.links) ? col.links : []).entries()) {
543
+ if (l && typeof l === "object") push(l.id, `header.nav[${i}].columns[${j}].links[${k}]`);
544
+ }
545
+ }
546
+ }
547
+ for (const [i, c] of (Array.isArray(header.ctas) ? header.ctas : []).entries()) {
548
+ if (c && typeof c === "object") push(c.id, `header.ctas[${i}]`);
549
+ }
550
+ if (header.memberCue && typeof header.memberCue === "object") push(header.memberCue.id, "header.memberCue");
551
+ const u = header.utilityNav;
552
+ if (u && typeof u === "object" && !Array.isArray(u)) {
553
+ for (const [i, l] of (Array.isArray(u.links) ? u.links : []).entries()) {
554
+ if (l && typeof l === "object") push(l.id, `header.utilityNav.links[${i}]`);
555
+ }
556
+ if (u.memberCue && typeof u.memberCue === "object") push(u.memberCue.id, "header.utilityNav.memberCue");
557
+ }
558
+ }
559
+ const footer = config?.footer;
560
+ if (footer && typeof footer === "object" && !Array.isArray(footer)) {
561
+ for (const [i, col] of (Array.isArray(footer.columns) ? footer.columns : []).entries()) {
562
+ if (col == null || typeof col !== "object") continue;
563
+ for (const [j, l] of (Array.isArray(col.links) ? col.links : []).entries()) {
564
+ if (l && typeof l === "object") push(l.id, `footer.columns[${i}].links[${j}]`);
565
+ }
566
+ }
567
+ }
568
+ return out;
569
+ }
570
+
571
+ /** `duplicate-cta`: two or more actionable items in one ChromeConfig resolve
572
+ * to the SAME governed identity. Almost always a copy-paste authoring
573
+ * mistake — the click listener's `closest("[data-tot-el]")` match and every
574
+ * downstream analytics query assume one element per id, so a duplicate
575
+ * silently merges two distinct interactions' data. WARN, never blocks: the
576
+ * page still renders and both elements still work.
577
+ */
578
+ function findDuplicateCtaIds(config, file) {
579
+ const byId = new Map();
580
+ for (const { id, path } of collectChromeActionableIds(config)) {
581
+ if (!byId.has(id)) byId.set(id, []);
582
+ byId.get(id).push(path);
583
+ }
584
+ const out = [];
585
+ for (const [id, paths] of byId) {
586
+ if (paths.length > 1) {
587
+ out.push(
588
+ mk(WARN, "duplicate-cta", file,
589
+ `id "${id}" is declared on ${paths.length} actionable items (${paths.join(", ")}) — data-tot-el must be unique per element; the click listener's closest() match and downstream analytics both assume one`,
590
+ "give every occurrence but one a distinct id"),
591
+ );
592
+ }
593
+ }
594
+ return out;
595
+ }
596
+
597
+ /**
598
+ * `cta-id-drift`: a governed actionable item's `data-tot-el` changed value
599
+ * between two versions of the SAME chrome.json, matched by structural
600
+ * position (e.g. "header.ctas[0]") — the same slot in both versions. Per
601
+ * dt-contract-v1's ledger `data-tot-el` is a PERMANENT identity ("a change
602
+ * here is DRIFT and is a defect"), unlike `data-tot-placement` which is
603
+ * expected to move on redesign.
604
+ *
605
+ * `previous` is an OPTIONAL caller-supplied prior chrome.json (already
606
+ * parsed) — this static, single-snapshot validator has no version history of
607
+ * its own, and no existing publish/preview utility in this repo diffs the
608
+ * CTA IDENTITY LIST across versions (apps/storefront/src/lib/publish and
609
+ * .../preview diff release ARTIFACT BYTES for the release panel, never this)
610
+ * to hook into instead. A version-aware caller (a future publish-time check,
611
+ * `tot validate --against <ref>`) supplies `previous`; this run alone never
612
+ * invents one, so omitting it is a silent no-op, not a missing check.
613
+ *
614
+ * A reorder/insert that shifts array positions can produce a false
615
+ * positive/negative here (position, not the item's own identity, is the
616
+ * match key) — a known limitation of matching without a stronger anchor.
617
+ */
618
+ function findCtaIdDrift(previous, current, file) {
619
+ if (previous == null || typeof previous !== "object") return [];
620
+ const before = new Map(collectChromeActionableIds(previous).map((e) => [e.path, e.id]));
621
+ const out = [];
622
+ for (const { id, path } of collectChromeActionableIds(current)) {
623
+ const priorId = before.get(path);
624
+ if (priorId !== undefined && priorId !== id) {
625
+ out.push(
626
+ mk(WARN, "cta-id-drift", file,
627
+ `${path}.id changed from "${priorId}" to "${id}" — data-tot-el is a permanent identity; a change here is drift, not a redesign, and breaks continuity with everything already recorded under the old id`,
628
+ "keep the original id (a deliberate identity migration is a separate, tracked decision, not an incidental edit)"),
629
+ );
630
+ }
631
+ }
632
+ return out;
633
+ }
634
+
635
+ // Compact mirror of @tot/public-runtime tracking-id-derivation.ts's trackable-
636
+ // tag set and `isWellFormedTrackingId` shape check — this script can't import
637
+ // that TS package at runtime (same published-CLI/no-build-step constraint as
638
+ // the chrome-shape mirrors above). Deliberately simplified: full structural
639
+ // derivation (region path, confidence) needs a real parser and an ancestor
640
+ // stack; this is a flat regex tag scan (the same style as HREF_RE/SRC_RE
641
+ // above), so it only answers "does this element carry a well-formed
642
+ // data-tot-el at all", not "what would a good one look like".
643
+ const ACTIONABLE_TAG_RE = /<([a-z][a-z0-9-]*)\b([^>]*)>/gi;
644
+ const TRACKABLE_HTML_TAGS = new Set(["a", "area", "button", "details", "summary", "form", "input", "select", "textarea", "label"]);
645
+ const TRACKABLE_HTML_ROLES = new Set(["button", "link", "tab", "menuitem", "switch", "checkbox"]);
646
+
647
+ function htmlAttr(attrs, name) {
648
+ const m = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, "i").exec(attrs);
649
+ return m ? m[1] : undefined;
650
+ }
651
+
652
+ /** Mirror of tracking-id-derivation.ts's `isTrackableNode`: an `<a>`/`<area>`
653
+ * needs a real `href`; the fixed tag set is always a candidate; anything
654
+ * else needs an interactive ARIA role. */
655
+ function isTrackableHtmlTag(tag, attrs) {
656
+ if (tag === "a" || tag === "area") {
657
+ const href = htmlAttr(attrs, "href");
658
+ return typeof href === "string" && href.trim().length > 0;
659
+ }
660
+ if (TRACKABLE_HTML_TAGS.has(tag)) return true;
661
+ const role = htmlAttr(attrs, "role");
662
+ return typeof role === "string" && TRACKABLE_HTML_ROLES.has(role.trim().toLowerCase());
663
+ }
664
+
665
+ /** Mirror of tracking-id-derivation.ts's `isWellFormedTrackingId`: lowercase
666
+ * kebab tokens, non-empty, within the same 64-char budget. */
667
+ function isWellFormedCtaId(el) {
668
+ return typeof el === "string" && el.length > 0 && el.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(el);
669
+ }
670
+
671
+ /**
672
+ * `cta-missing-id`: a tenant-authored HTML fragment's actionable element
673
+ * carries no well-formed `data-tot-el`. Tenant fragments ship NO `data-tot-*`
674
+ * at all pre-derivation (docs/architecture/interaction-tracking-attribute-
675
+ * contract.md "Deriving data-tot-el") — `validateChromeConfig`'s hard
676
+ * either/or failure only reaches the SCHEMA-GOVERNED chrome.json path, never
677
+ * a raw fragment, so an un-identified CTA here would otherwise ship silently
678
+ * unmeasurable: the page renders fine, every other check passes, and the
679
+ * element is simply invisible to interaction tracking. WARN, never blocks.
680
+ *
681
+ * Exported so the `tot dev` save-loop nudge (scripts/dev/checkout-watch.mjs)
682
+ * can reuse the SAME detection on a single saved file, in-process, rather
683
+ * than re-implementing it — dt-assign-ids-step is a discoverability/wiring
684
+ * unit, not a new detector.
685
+ */
686
+ export function findMissingCtaIds(html, file) {
687
+ const out = [];
688
+ ACTIONABLE_TAG_RE.lastIndex = 0;
689
+ let m;
690
+ while ((m = ACTIONABLE_TAG_RE.exec(html))) {
691
+ const tag = m[1].toLowerCase();
692
+ const attrs = m[2] || "";
693
+ if (!isTrackableHtmlTag(tag, attrs)) continue;
694
+ const totEl = htmlAttr(attrs, "data-tot-el");
695
+ if (isWellFormedCtaId(totEl)) continue;
696
+ out.push(
697
+ mk(WARN, "cta-missing-id", file,
698
+ `a <${tag}> actionable element has ${totEl ? `a malformed data-tot-el (${JSON.stringify(totEl)})` : "no data-tot-el"} — it renders and functions but is invisible to interaction tracking`,
699
+ CTA_MISSING_ID_FIX),
700
+ );
701
+ }
702
+ return out;
703
+ }
704
+
413
705
  // --- filesystem helpers ------------------------------------------------------
414
706
  function readJsonSafe(path) {
415
707
  try {
@@ -513,10 +805,13 @@ function resolvePlatformRouteOwnership(config, hostIsPlatform) {
513
805
  /**
514
806
  * Full static validation of a tenant directory.
515
807
  * @param {string} tenantDir absolute path to the tenant dir (content/ public/ theme.json [.tot/])
516
- * @param {{tenantId?:string, scope?:string, mode?:"monorepo"|"workspace"}} [opts]
808
+ * @param {{tenantId?:string, scope?:string, mode?:"monorepo"|"workspace", previousChromeJson?:any}} [opts]
517
809
  * `mode` — "monorepo" (default): served by this platform, so commerce tenants own
518
810
  * the framework routes. "workspace": a standalone checkout, conservative about
519
811
  * platform-route ownership unless the config resolves the host.
812
+ * `previousChromeJson` — an already-parsed PRIOR version of content/chrome.json,
813
+ * supplied by a version-aware caller, so the `cta-id-drift` check has something
814
+ * to compare against (see findCtaIdDrift above). Omitted ⇒ that check is a no-op.
520
815
  * @returns {{ok:boolean, findings:Finding[]}}
521
816
  */
522
817
  export function validateTenant(tenantDir, opts = {}) {
@@ -615,6 +910,11 @@ export function validateTenant(tenantDir, opts = {}) {
615
910
  findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
616
911
  } else if (name === "home.json") {
617
912
  findings.push(...validateHomeDoc(value, "content/home.json"));
913
+ } else if (name === "chrome.json") {
914
+ findings.push(...findDuplicateCtaIds(value, "content/chrome.json"));
915
+ if (opts.previousChromeJson != null) {
916
+ findings.push(...findCtaIdDrift(opts.previousChromeJson, value, "content/chrome.json"));
917
+ }
618
918
  }
619
919
  }
620
920
  }
@@ -667,6 +967,9 @@ export function validateTenant(tenantDir, opts = {}) {
667
967
  "use a bare <style> tag"),
668
968
  );
669
969
  }
970
+ // actionable elements with no (or malformed) governed identity — advisory,
971
+ // never blocking (see findMissingCtaIds above).
972
+ findings.push(...findMissingCtaIds(html, r));
670
973
 
671
974
  // link portability + resolution
672
975
  for (const m of html.matchAll(HREF_RE)) {
@@ -826,6 +1129,8 @@ if (isMain) {
826
1129
  const errors = findings.filter((f) => f.level === ERROR);
827
1130
  const warns = findings.filter((f) => f.level === WARN);
828
1131
  console.log(`\ntot validate — ${tenantId ?? dir}\n`);
1132
+ // Named, grouped callouts FIRST — see printLoudAdvisories above.
1133
+ printLoudAdvisories(findings, { log: (s) => console.log(s) });
829
1134
  for (const f of findings) {
830
1135
  const tag = f.level === ERROR ? "✗" : "⚠";
831
1136
  console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
@@ -65,6 +65,7 @@ import { resolveDevSourceRoot } from "./dev/tenant-source-root.mjs";
65
65
  import { tenantDirRelative, tenantDirSegments } from "./lib/tenant-dirs.mjs";
66
66
  import { firstFreePort } from "./dev/port-check.mjs";
67
67
  import { resync, startWatch } from "./dev/checkout-watch.mjs";
68
+ import { findMissingCtaIds } from "./tenant/validate.mjs";
68
69
 
69
70
  const here = dirname(fileURLToPath(import.meta.url));
70
71
  const repoRoot = resolve(here, ".."); // scripts/ -> repo root
@@ -169,6 +170,42 @@ const grafts = [];
169
170
  const watchers = [];
170
171
  let cleanedUp = false;
171
172
 
173
+ // --- dev-loop nudge: "assign stable tracking ids" (unit dt-assign-ids-step) ---
174
+ // The save→reload loop is a point the developer is ALREADY paying attention to
175
+ // (they just edited and are watching for the reload) — the same named,
176
+ // actionable step surfaced at `tot validate` / `tot submit` / `tot preview`
177
+ // (scripts/tenant/validate.mjs's CTA_MISSING_ID_FIX / LOUD_ADVISORY_RULES)
178
+ // belongs here too, in the SAME words, or a developer sees a different warning
179
+ // depending on which command happened to notice first. Reuses that module's
180
+ // `findMissingCtaIds` detector directly — this unit wires an existing detector
181
+ // into a new touchpoint, it does not invent a new one. Advisory only: a
182
+ // scan failure (unreadable file, mid-write) never blocks the reload.
183
+ const trackingIdNudged = new Set();
184
+ function nudgeTrackingIds(absPath, label) {
185
+ if (!/\.html?$/i.test(absPath)) return;
186
+ let html;
187
+ try {
188
+ html = readFileSync(absPath, "utf8");
189
+ } catch {
190
+ return; // deleted mid-scan, or a transient editor write — never block the reload over this
191
+ }
192
+ const findings = findMissingCtaIds(html, label);
193
+ if (!findings.length) {
194
+ trackingIdNudged.delete(label); // fixed — nudge again if it regresses later
195
+ return;
196
+ }
197
+ // One nudge per file per session while it stays unfixed, not once per keystroke-driven save.
198
+ if (trackingIdNudged.has(label)) return;
199
+ trackingIdNudged.add(label);
200
+ console.log(
201
+ `\n ⚠ assign stable tracking ids — ${findings.length} actionable element(s) in ${label} have no data-tot-el`,
202
+ );
203
+ console.log(` They render and work fine, but stay invisible to interaction tracking until identified.`);
204
+ console.log(
205
+ ` Assign them: GET/POST /api/admin/tracking-writeback proposes ids for review as a candidate PR, or run \`tot validate\` for the full list.\n`,
206
+ );
207
+ }
208
+
172
209
  function isSymlink(p) {
173
210
  try {
174
211
  return lstatSync(p).isSymbolicLink();
@@ -275,7 +312,10 @@ if (workspacePath) {
275
312
  // `startWatch` polls by default (reliable — native fs.watch dropped editor
276
313
  // atomic-saves on macOS); TOT_DEV_NATIVE_WATCH=1 opts into event-driven watch.
277
314
  try {
278
- const w = startWatch(source, isDir, (rel) => resync(source, target, rel));
315
+ const w = startWatch(source, isDir, (rel) => {
316
+ resync(source, target, rel);
317
+ nudgeTrackingIds(isDir ? join(source, rel) : source, rel ? `${wsRel}/${rel}` : wsRel);
318
+ });
279
319
  watchers.push(w);
280
320
  } catch (e) {
281
321
  console.warn(` ⚠ could not watch ${wsRel} (edits won't hot-reload): ${e?.message}`);
@@ -289,8 +329,24 @@ if (workspacePath) {
289
329
  cleanupGrafts();
290
330
  process.exit(1);
291
331
  }
292
- } else if (!existsSync(resolve(repoRoot, ...tenantDirSegments(tenant), "content"))) {
293
- console.warn(`⚠ no tenants/${tenant}/content/ found — the tenant may not be registered yet.`);
332
+ } else {
333
+ // MONOREPO mode: content/ is already in place (nothing grafted), so nothing
334
+ // else here watches it at the JS level — astro's own Vite HMR reloads on save
335
+ // silently. Add a lightweight watch for the SAME "assign stable tracking ids"
336
+ // nudge the standalone graft loop gets above (see nudgeTrackingIds).
337
+ const monorepoContentDir = resolve(repoRoot, ...tenantDirSegments(tenant), "content");
338
+ if (!existsSync(monorepoContentDir)) {
339
+ console.warn(`⚠ no tenants/${tenant}/content/ found — the tenant may not be registered yet.`);
340
+ } else {
341
+ try {
342
+ const w = startWatch(monorepoContentDir, true, (rel) => {
343
+ if (rel) nudgeTrackingIds(join(monorepoContentDir, rel), `content/${rel}`);
344
+ });
345
+ watchers.push(w);
346
+ } catch (e) {
347
+ console.warn(` ⚠ could not watch tenants/${tenant}/content/ for the tracking-id nudge: ${e?.message}`);
348
+ }
349
+ }
294
350
  }
295
351
 
296
352
  // Resolve the port BEFORE printing a URL or booting astro. Vite's `strictPort`
@@ -1,90 +1,17 @@
1
- /* Storefront early-access landing — interactions + analytics.
1
+ /* Storefront early-access landing — interactions.
2
2
  * Served at /tenants/home/js/storefront.js (mirrored from tenants/home/public/).
3
- *
4
- * ---- tot20 analytics: public reportAnalytics endpoint --------------------
5
- * GET /reportAnalytics/{origin}/{pluginVersion}/{subject}/{action}?...
6
- * origin=totcom => anonymous marketing path (user_id null, device-id based).
7
- * apiKeyNotRequired + allowCORs, so we fire a fire-and-forget GET beacon.
8
- *
9
- * Beacon transport is an Image() GET on purpose: the storefront CSP is
10
- * `img-src https:` (allowed) but `connect-src 'self'` (a cross-origin fetch to
11
- * qa.tokenoftrust.com would be BLOCKED). An <img> GET sidesteps connect-src.
12
- *
13
- * Detail rides in utm_content/utm_campaign (the endpoint's eventProperties).
14
- * Events use subject=cust and are shaped onto two EXISTING registered wildcards
15
- * in tot20's analytics schema (analyticEvents.json), so they validate (HTTP 200)
16
- * with no schema change:
17
- * - views → cust_viewed_totcom_page_* (the anonymous tokenoftrust.com convention)
18
- * - clicks → cust_clicked_btn_*
19
- * ------------------------------------------------------------------------- */
3
+ */
20
4
  (function () {
21
- const BASE = "https://qa.tokenoftrust.com/api"; // tot20 public API (qa; swap for prod)
22
- const ORIGIN = "totcom", PLUGIN = "1.0", SUBJECT = "cust", APP_DOMAIN = "tokenoftrust.com";
23
-
24
- function deviceId() {
25
- try {
26
- const k = "ampDeviceId";
27
- let v = localStorage.getItem(k);
28
- if (!v) { v = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random()); localStorage.setItem(k, v); }
29
- return v;
30
- } catch (e) { return "anon"; }
31
- }
32
- function track(action, detail, campaign) {
33
- try {
34
- const qs = new URLSearchParams({
35
- appDomain: APP_DOMAIN, ampDeviceId: deviceId(),
36
- title: document.title, referrer: document.referrer || "",
37
- lang: navigator.language || "", screen_w: String(screen.width), screen_h: String(screen.height)
38
- });
39
- if (detail) qs.set("utm_content", detail);
40
- qs.set("utm_campaign", campaign || "storefront_early_access");
41
- qs.set("utm_source", "storefront-landing"); qs.set("utm_medium", "web");
42
- const url = BASE + "/reportAnalytics/" + ORIGIN + "/" + PLUGIN + "/" + SUBJECT + "/" + encodeURIComponent(action) + "?" + qs.toString();
43
- // Image() GET beacon — allowed by img-src https:; a fetch would hit connect-src 'self'.
44
- new Image().src = url;
45
- } catch (e) { /* fire-and-forget */ }
46
- }
47
- window.__totTrack = track;
48
-
49
- // 1) page view
50
- track("viewed_totcom_page_storefront_early_access");
51
-
52
- // 2) every click on a labeled control (captures all CTAs, nav, deck links, login)
53
- document.addEventListener("click", function (e) {
54
- const el = e.target.closest("[data-ev]");
55
- if (el) { const l = el.getAttribute("data-ev"); track("clicked_btn_" + l, l); }
56
- });
57
-
58
- // 3) section visibility (which parts of the page people actually reach)
59
- if ("IntersectionObserver" in window) {
60
- const seen = {};
61
- const io = new IntersectionObserver(function (entries) {
62
- entries.forEach(function (en) {
63
- const name = en.target.getAttribute("data-section");
64
- if (en.isIntersecting && !seen[name]) { seen[name] = 1; track("viewed_totcom_page_section_" + name, name); }
65
- });
66
- }, { threshold: .4 });
67
- document.querySelectorAll("[data-section]").forEach(function (s) { io.observe(s); });
68
- }
69
-
70
- // 4) scroll depth
71
- const marks = [25, 50, 75, 100], hit = {};
72
- window.addEventListener("scroll", function () {
73
- const sc = document.documentElement, pct = Math.round((sc.scrollTop + window.innerHeight) / sc.scrollHeight * 100);
74
- marks.forEach(function (m) { if (pct >= m && !hit[m]) { hit[m] = 1; track("viewed_totcom_page_scroll_" + m, String(m)); } });
75
- }, { passive: true });
76
-
77
- // 5) comparison expand/collapse
5
+ // 1) comparison expand/collapse
78
6
  document.querySelectorAll(".cmp .top").forEach(function (btn) {
79
7
  btn.addEventListener("click", function () {
80
8
  const card = btn.closest(".cmp"), open = card.getAttribute("aria-expanded") === "true";
81
9
  card.setAttribute("aria-expanded", open ? "false" : "true");
82
10
  btn.setAttribute("aria-expanded", open ? "false" : "true");
83
- // (analytics for the open already fired via data-ev on the button)
84
11
  });
85
12
  });
86
13
 
87
- // 6) persona tabs
14
+ // 2) persona tabs
88
15
  const tabs = document.querySelectorAll(".seg [data-persona]");
89
16
  const panels = document.querySelectorAll(".ppanel");
90
17
  tabs.forEach(function (tab) {
@@ -95,12 +22,6 @@
95
22
  });
96
23
  });
97
24
 
98
- // 7) time on page (sent on leave)
99
- const start = Date.now();
100
- window.addEventListener("pagehide", function () {
101
- track("viewed_totcom_page_exit", String(Math.round((Date.now() - start) / 1000)));
102
- });
103
-
104
25
  // ---- request-access modal --------------------------------------------------
105
26
  // Board address the mailto: fallback targets (mirrors the server route's
106
27
  // recipient). Kept in sync with apps/storefront/src/pages/api/request-access.ts.
@@ -108,13 +29,12 @@
108
29
  const modal = document.getElementById("ra-modal");
109
30
  const form = modal && modal.querySelector("form.ra");
110
31
 
111
- function openModal(source) {
32
+ function openModal() {
112
33
  if (!modal) return;
113
34
  modal.classList.remove("done");
114
35
  if (form) form.classList.remove("has-error");
115
36
  modal.classList.add("on");
116
37
  document.body.style.overflow = "hidden";
117
- track("clicked_btn_request_access_open", source || "unknown");
118
38
  const first = modal.querySelector("input[name=email]");
119
39
  if (first) setTimeout(function () { first.focus(); }, 40);
120
40
  }
@@ -128,7 +48,7 @@
128
48
  document.querySelectorAll('a[href="#request"]').forEach(function (a) {
129
49
  a.addEventListener("click", function (e) {
130
50
  e.preventDefault();
131
- openModal(a.getAttribute("data-ev") || "cta");
51
+ openModal();
132
52
  });
133
53
  });
134
54
  if (modal) {
@@ -164,7 +84,6 @@
164
84
  phone: String(fd.get("phone") || "").trim(),
165
85
  details: String(fd.get("details") || "").trim(),
166
86
  };
167
- track("clicked_btn_request_access_submit", fields.email ? "with_email" : "no_email");
168
87
 
169
88
  const btn = form.querySelector("button[type=submit]");
170
89
  const label = btn ? btn.textContent : "";