fullstackgtm 0.30.0 → 0.31.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/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.31.0] — 2026-06-18
9
+
10
+ ### Added
11
+
12
+ - **Vendor logos in the market-map report.** `MarketVendor` gains an optional
13
+ `logo` field; `marketMapToHtml` renders it beside the name in the legend and
14
+ above the matrix column headers, degrading to the numbered swatch / plain name
15
+ when absent. Only `data:image/…` URIs are honored — keeping the report
16
+ self-contained (no external requests, survives being saved or emailed) and safe
17
+ under a strict `img-src data:` CSP. The hosted service extracts a canonical logo
18
+ from each vendor's homepage; CLI users can set `logo` by hand.
19
+
8
20
  ## [0.30.0] — 2026-06-17
9
21
 
10
22
  Connector fixes — the two concrete defects a real HubSpot/Salesforce shop hits.
package/dist/market.d.ts CHANGED
@@ -81,6 +81,14 @@ export type MarketVendor = {
81
81
  * obvious from the vendor's own pricing page (which the map captures).
82
82
  */
83
83
  acvBand?: string;
84
+ /**
85
+ * Optional brand logo for the report (legend + matrix headers). A `data:` URI
86
+ * keeps the rendered report self-contained — no external requests, survives
87
+ * being saved or emailed. The hosted service extracts it from the vendor's
88
+ * homepage; CLI users can set it by hand. Renderers degrade gracefully to the
89
+ * numbered swatch when absent.
90
+ */
91
+ logo?: string;
84
92
  notes?: string;
85
93
  };
86
94
  export type MarketAxis = {
@@ -28,6 +28,17 @@ function escapeHtml(value) {
28
28
  .replace(/>/g, ">")
29
29
  .replace(/"/g, """);
30
30
  }
31
+ /**
32
+ * A small brand logo for legend rows / matrix headers. Accepts only `data:image/`
33
+ * URIs — self-contained (no external request, survives save/email) and safe under
34
+ * the report's `img-src data:` CSP (an SVG loaded via <img> can't execute script).
35
+ * Returns "" when absent so callers fall back to the numbered swatch / plain name.
36
+ */
37
+ function logoImg(logo, cls) {
38
+ if (typeof logo !== "string" || !logo.startsWith("data:image/"))
39
+ return "";
40
+ return `<img class="${cls}" src="${escapeHtml(logo)}" alt="" loading="lazy">`;
41
+ }
31
42
  /**
32
43
  * Serialize JSON for embedding inside an inline <script> block. JSON.stringify
33
44
  * does not escape `<`, `>`, `&`, or the U+2028/U+2029 line separators, so a
@@ -291,6 +302,7 @@ function axisSectionsHtml(config, set) {
291
302
  // The number inside each bubble resolves dense clusters that name labels
292
303
  // never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
293
304
  const points = pointsFor(px, py);
305
+ const logoByVendor = new Map(config.vendors.map((vendor) => [vendor.id, vendor.logo]));
294
306
  const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
295
307
  const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
296
308
  const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
@@ -305,7 +317,7 @@ function axisSectionsHtml(config, set) {
305
317
  ? `${(share * 100).toFixed(1)}%`
306
318
  : "—"
307
319
  : `${loudCounts.get(point.vendorId) ?? 0} loud`;
308
- return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${e(point.name)}${isAnchor ? " ·&nbsp;anchor" : ""}</td><td class="num">${measure}</td></tr>`;
320
+ return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${logoImg(logoByVendor.get(point.vendorId), "v-logo")}${e(point.name)}${isAnchor ? " ·&nbsp;anchor" : ""}</td><td class="num">${measure}</td></tr>`;
309
321
  })
310
322
  .join("");
311
323
  const legendMeasureHead = useScale ? "est. share" : "loud";
@@ -426,7 +438,7 @@ export function marketMapToHtml(config, set) {
426
438
  `<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`);
427
439
  };
428
440
  const vendorHeads = config.vendors
429
- .map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`)
441
+ .map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}">${logoImg(vendor.logo, "vh-logo")}<span>${e(vendor.name)}</span></th>`)
430
442
  .join("");
431
443
  // Claims grouped by front state, each group a collapsed <details> whose
432
444
  // summary carries the stats a skimmer needs; the full matrix is one click
@@ -562,6 +574,8 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
562
574
  figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
563
575
  g.bubble { cursor:pointer; }
564
576
  g.bubble.dim { opacity:0.25; transition:opacity .12s; }
577
+ img.v-logo { width:15px; height:15px; border-radius:3px; object-fit:contain; vertical-align:-3px; margin-right:6px; background:#fff; }
578
+ th.vh img.vh-logo { display:block; width:18px; height:18px; border-radius:3px; object-fit:contain; margin:0 auto 4px; background:#fff; }
565
579
  table.legend tbody tr { cursor:default; }
566
580
  table.legend tbody tr.hl td { background:var(--faint); }
567
581
  .map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
package/src/market.ts CHANGED
@@ -94,6 +94,14 @@ export type MarketVendor = {
94
94
  * obvious from the vendor's own pricing page (which the map captures).
95
95
  */
96
96
  acvBand?: string;
97
+ /**
98
+ * Optional brand logo for the report (legend + matrix headers). A `data:` URI
99
+ * keeps the rendered report self-contained — no external requests, survives
100
+ * being saved or emailed. The hosted service extracts it from the vendor's
101
+ * homepage; CLI users can set it by hand. Renderers degrade gracefully to the
102
+ * numbered swatch when absent.
103
+ */
104
+ logo?: string;
97
105
  notes?: string;
98
106
  };
99
107
 
@@ -40,6 +40,17 @@ function escapeHtml(value: string): string {
40
40
  .replace(/"/g, "&quot;");
41
41
  }
42
42
 
43
+ /**
44
+ * A small brand logo for legend rows / matrix headers. Accepts only `data:image/`
45
+ * URIs — self-contained (no external request, survives save/email) and safe under
46
+ * the report's `img-src data:` CSP (an SVG loaded via <img> can't execute script).
47
+ * Returns "" when absent so callers fall back to the numbered swatch / plain name.
48
+ */
49
+ function logoImg(logo: string | undefined, cls: string): string {
50
+ if (typeof logo !== "string" || !logo.startsWith("data:image/")) return "";
51
+ return `<img class="${cls}" src="${escapeHtml(logo)}" alt="" loading="lazy">`;
52
+ }
53
+
43
54
  /**
44
55
  * Serialize JSON for embedding inside an inline <script> block. JSON.stringify
45
56
  * does not escape `<`, `>`, `&`, or the U+2028/U+2029 line separators, so a
@@ -342,6 +353,7 @@ function axisSectionsHtml(
342
353
  // The number inside each bubble resolves dense clusters that name labels
343
354
  // never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
344
355
  const points = pointsFor(px, py);
356
+ const logoByVendor = new Map(config.vendors.map((vendor) => [vendor.id, vendor.logo]));
345
357
  const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
346
358
  const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
347
359
  const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
@@ -356,7 +368,7 @@ function axisSectionsHtml(
356
368
  ? `${(share * 100).toFixed(1)}%`
357
369
  : "—"
358
370
  : `${loudCounts.get(point.vendorId) ?? 0} loud`;
359
- return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${e(point.name)}${isAnchor ? " ·&nbsp;anchor" : ""}</td><td class="num">${measure}</td></tr>`;
371
+ return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${logoImg(logoByVendor.get(point.vendorId), "v-logo")}${e(point.name)}${isAnchor ? " ·&nbsp;anchor" : ""}</td><td class="num">${measure}</td></tr>`;
360
372
  })
361
373
  .join("");
362
374
  const legendMeasureHead = useScale ? "est. share" : "loud";
@@ -488,7 +500,7 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
488
500
  const vendorHeads = config.vendors
489
501
  .map(
490
502
  (vendor) =>
491
- `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`,
503
+ `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}">${logoImg(vendor.logo, "vh-logo")}<span>${e(vendor.name)}</span></th>`,
492
504
  )
493
505
  .join("");
494
506
 
@@ -632,6 +644,8 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
632
644
  figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
633
645
  g.bubble { cursor:pointer; }
634
646
  g.bubble.dim { opacity:0.25; transition:opacity .12s; }
647
+ img.v-logo { width:15px; height:15px; border-radius:3px; object-fit:contain; vertical-align:-3px; margin-right:6px; background:#fff; }
648
+ th.vh img.vh-logo { display:block; width:18px; height:18px; border-radius:3px; object-fit:contain; margin:0 auto 4px; background:#fff; }
635
649
  table.legend tbody tr { cursor:default; }
636
650
  table.legend tbody tr.hl td { background:var(--faint); }
637
651
  .map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;